Merge remote-tracking branch 'origin/main' into teeohhem/expand-e2e-coverage

This commit is contained in:
Tom Alexander 2026-04-17 09:54:19 -04:00
commit 65b93f49ca
367 changed files with 34870 additions and 6021 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/cli": patch
---
Add alerts page (Shift+A) with overview and recent trigger history

View file

@ -0,0 +1,5 @@
---
"@hyperdx/cli": patch
---
Add event pattern mining view (Shift+P) with sampled estimation and drill-down

View file

@ -0,0 +1,5 @@
---
"@hyperdx/common-utils": patch
---
Add Drain log template mining library (ported from browser-drain)

View file

@ -0,0 +1,8 @@
---
'@hyperdx/otel-collector': minor
---
feat: Bump OTel Collector from 0.147.0 to 0.149.0
Upgrade the OpenTelemetry Collector and all its components from v0.147.0 to
v0.149.0 (core providers from v1.53.0 to v1.55.0).

View file

@ -0,0 +1,10 @@
---
"@hyperdx/cli": minor
---
**Breaking:** Replace `-s`/`--server` flag with `-a`/`--app-url` across all CLI commands (except `upload-sourcemaps`). Users should now provide the HyperDX app URL instead of the API URL — the CLI derives the API URL by appending `/api`.
- `hdx auth login` now prompts interactively for login method, app URL, and credentials (no flags required)
- Expired/missing sessions prompt for re-login with the last URL autofilled instead of printing an error
- Add URL input validation and post-login session verification
- Existing saved sessions are auto-migrated from `apiUrl` to `appUrl`

View file

@ -0,0 +1,5 @@
---
"@hyperdx/api": minor
---
Add an MCP (Model Context Protocol) server to the HyperDX API, enabling AI assistants (Claude, Cursor, OpenCode, etc.) to query observability data, manage dashboards, and explore data sources directly via standardized tool calls.

View file

@ -0,0 +1,11 @@
---
'@hyperdx/otel-collector': patch
---
refactor: Deprecate clickhouse.json feature gate in favor of per-exporter json config
Replace the upstream-deprecated `--feature-gates=clickhouse.json` CLI flag with
the per-exporter `json: true` config option controlled by
`HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE`. The old
`OTEL_AGENT_FEATURE_GATE_ARG` is still supported for backward compatibility but
prints a deprecation warning when `clickhouse.json` is detected.

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Support alerts on Raw SQL Number Charts

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: Fix "Copy entire row as JSON" button crashing on rows with non-string values

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
Dedupe source validation issue toasts so repeated source refetches update a single notification instead of stacking duplicates.

View file

@ -0,0 +1,9 @@
---
"@hyperdx/otel-collector": patch
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
"@hyperdx/cli": patch
---
feat: Add between-type alert thresholds

View file

@ -0,0 +1,12 @@
---
'@hyperdx/otel-collector': minor
---
feat: Migrate OTel Collector build to use OCB (OpenTelemetry Collector Builder)
Replace the pre-built otel/opentelemetry-collector-contrib image with a custom
binary built via OCB. This enables adding custom receiver/processor components
in the future while including only the components HyperDX needs. The collector
version is now centralized in `.env` via `OTEL_COLLECTOR_VERSION` and
`OTEL_COLLECTOR_CORE_VERSION`, with `builder-config.yaml` using templatized
placeholders substituted at Docker build time.

View file

@ -0,0 +1,5 @@
---
"@hyperdx/cli": patch
---
Add `o` keybinding to open the current trace/span in the HyperDX web app from the TUI. Deep-links to the exact view with side panel, tab, and span selection preserved. Works for both trace and log sources.

View file

@ -0,0 +1,5 @@
---
"@hyperdx/cli": patch
---
Optimize event detail and trace waterfall queries; add trace detail page and waterfall scrolling

View file

@ -0,0 +1,13 @@
---
'@hyperdx/otel-collector': minor
---
feat: Add missing core extensions, commonly-used contrib processors/receivers, and filestorage extension
Add the two missing core extensions (memorylimiterextension, zpagesextension),
12 commonly-used contrib processors (attributes, filter, resource, k8sattributes,
tailsampling, probabilisticsampler, span, groupbyattrs, redaction, logdedup,
metricstransform, cumulativetodelta), 4 commonly-used contrib receivers
(filelog, dockerstats, k8scluster, kubeletstats), and the filestorage extension
(used for persistent sending queue in the clickhouse exporter) to
builder-config.yaml.

View file

@ -0,0 +1,8 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
"@hyperdx/cli": patch
---
feat: Add additional alert threshold types

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": minor
"@hyperdx/api": minor
"@hyperdx/app": minor
---
Introduces Shared Filters, enabling teams to pin and surface common filters across all members.

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Add Python Runtime Metrics dashboard template

View file

@ -0,0 +1,5 @@
---
"@hyperdx/common-utils": patch
---
fix: Skip rendering empty SQL dashboard filter

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Implement alerting for Raw SQL-based dashboard tiles

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: time selector always resets to 00:00

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Add alert history + ack to alert editor

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
Allow manually constructed /trace URLs to land in the existing search experience with the trace viewer opened from URL state. This keeps trace deep links user-friendly while reusing the search page for source selection, not-found handling, and trace inspection.

View file

@ -0,0 +1,5 @@
---
'@hyperdx/app': minor
---
Upgrade Mantine from v7 to v9 and remove react-hook-form-mantine dependency

View file

@ -23,6 +23,8 @@ Delegate to the **`playwright-test-generator`** agent (via the Agent tool). Pass
The agent will drive a real browser, execute the steps live, and produce spec code that follows HyperDX conventions. Review the output before proceeding.
NOTE: When there is an existing spec file covering the feature, add new tests to the existing file instead of creating a new one. This keeps related tests together and avoids fragmentation.
### 2. Test Execution
After the generator agent writes the file, run the test:

10
.env
View file

@ -8,8 +8,8 @@ NEXT_ALL_IN_ONE_IMAGE_NAME_DOCKERHUB=clickhouse/clickstack-all-in-one
ALL_IN_ONE_IMAGE_NAME_DOCKERHUB=hyperdx/hyperdx-all-in-one
NEXT_OTEL_COLLECTOR_IMAGE_NAME_DOCKERHUB=clickhouse/clickstack-otel-collector
OTEL_COLLECTOR_IMAGE_NAME_DOCKERHUB=hyperdx/hyperdx-otel-collector
CODE_VERSION=2.23.0
IMAGE_VERSION_SUB_TAG=.23.0
CODE_VERSION=2.23.2
IMAGE_VERSION_SUB_TAG=.23.2
IMAGE_VERSION=2
IMAGE_NIGHTLY_TAG=2-nightly
IMAGE_LATEST_TAG=latest
@ -38,5 +38,11 @@ HDX_DEV_OTEL_HTTP_PORT=4318
HDX_DEV_OTEL_METRICS_PORT=8888
HDX_DEV_OTEL_JSON_HTTP_PORT=14318
# Otel Collector version (used as Docker build arg for image tags and component versions)
# When bumping, look up the core version from the upstream manifest:
# https://github.com/open-telemetry/opentelemetry-collector-releases/blob/main/distributions/otelcol-contrib/manifest.yaml
OTEL_COLLECTOR_VERSION=0.149.0
OTEL_COLLECTOR_CORE_VERSION=1.55.0
# Otel/Clickhouse config
HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE=default

View file

@ -0,0 +1,569 @@
'use strict';
// Tests for the pure classification functions in pr-triage-classify.js.
// Uses Node's built-in test runner (no extra dependencies required).
// Run with: node --test .github/scripts/__tests__/pr-triage-classify.test.js
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
isTestFile, isTrivialFile, isCriticalFile,
computeSignals, determineTier, buildTierComment,
} = require('../pr-triage-classify');
// ── Test helpers ─────────────────────────────────────────────────────────────
/** Minimal PR object matching the shape returned by the GitHub API */
function makePR(login, ref) {
return { user: { login }, head: { ref } };
}
/** Minimal file entry matching the shape returned by pulls.listFiles */
function makeFile(filename, additions = 10, deletions = 5) {
return { filename, additions, deletions };
}
/** Classify a PR end-to-end from raw inputs (the common test path) */
function classify(login, ref, files) {
return determineTier(computeSignals(makePR(login, ref), files));
}
// ── File classification helpers ──────────────────────────────────────────────
describe('isTestFile', () => {
it('matches __tests__ directory', () => {
assert.ok(isTestFile('packages/api/src/__tests__/foo.test.ts'));
assert.ok(isTestFile('packages/app/src/components/__tests__/Foo.test.tsx'));
});
it('matches .test.* and .spec.* extensions', () => {
assert.ok(isTestFile('packages/app/src/Foo.test.tsx'));
assert.ok(isTestFile('packages/app/src/Foo.spec.js'));
assert.ok(isTestFile('packages/api/src/bar.test.ts'));
});
it('matches packages/app/tests/ prefix', () => {
assert.ok(isTestFile('packages/app/tests/e2e/navigation.ts'));
});
it('does not match regular source files', () => {
assert.ok(!isTestFile('packages/api/src/routers/foo.ts'));
assert.ok(!isTestFile('packages/app/src/App.tsx'));
});
});
describe('isTrivialFile', () => {
it('matches docs and images', () => {
assert.ok(isTrivialFile('README.md'));
assert.ok(isTrivialFile('docs/setup.txt'));
assert.ok(isTrivialFile('assets/logo.png'));
assert.ok(isTrivialFile('assets/icon.svg'));
});
it('matches lock files and yarn config', () => {
assert.ok(isTrivialFile('yarn.lock'));
assert.ok(isTrivialFile('package-lock.json'));
assert.ok(isTrivialFile('.yarnrc.yml'));
});
it('matches .changeset/ files', () => {
assert.ok(isTrivialFile('.changeset/some-change.md'));
assert.ok(isTrivialFile('.changeset/fancy-bears-dance.md'));
});
it('matches .env.example and .github/images/', () => {
assert.ok(isTrivialFile('.env.example'));
assert.ok(isTrivialFile('.github/images/screenshot.png'));
});
it('matches .github/scripts/ files', () => {
assert.ok(isTrivialFile('.github/scripts/pr-triage.js'));
assert.ok(isTrivialFile('.github/scripts/pr-triage-classify.js'));
});
it('matches .github/workflows/ files', () => {
assert.ok(isTrivialFile('.github/workflows/pr-triage.yml'));
assert.ok(isTrivialFile('.github/workflows/knip.yml'));
// main.yml and release.yml are also trivial per isTrivialFile, but they are
// caught first by isCriticalFile in computeSignals, so they still → Tier 4
assert.ok(isTrivialFile('.github/workflows/main.yml'));
});
it('does not match production source files', () => {
assert.ok(!isTrivialFile('packages/app/src/App.tsx'));
assert.ok(!isTrivialFile('packages/api/src/routers/logs.ts'));
assert.ok(!isTrivialFile('Makefile'));
assert.ok(!isTrivialFile('knip.json'));
});
});
describe('isCriticalFile', () => {
it('matches auth middleware', () => {
assert.ok(isCriticalFile('packages/api/src/middleware/auth.ts'));
assert.ok(isCriticalFile('packages/api/src/middleware/auth/index.ts'));
});
it('matches sensitive API routes', () => {
assert.ok(isCriticalFile('packages/api/src/routers/api/me.ts'));
assert.ok(isCriticalFile('packages/api/src/routers/api/team.ts'));
assert.ok(isCriticalFile('packages/api/src/routers/external-api/logs.ts'));
});
it('matches core data models', () => {
assert.ok(isCriticalFile('packages/api/src/models/user.ts'));
assert.ok(isCriticalFile('packages/api/src/models/team.ts'));
assert.ok(isCriticalFile('packages/api/src/models/teamInvite.ts'));
});
it('matches config, tasks, otel, clickhouse, and core CI workflows', () => {
assert.ok(isCriticalFile('packages/api/src/config.ts'));
assert.ok(isCriticalFile('packages/api/src/tasks/alertChecker.ts'));
assert.ok(isCriticalFile('packages/otel-collector/config.yaml'));
assert.ok(isCriticalFile('docker/clickhouse/config.xml'));
assert.ok(isCriticalFile('.github/workflows/main.yml'));
assert.ok(isCriticalFile('.github/workflows/release.yml'));
});
it('does NOT flag non-core workflow files as critical', () => {
assert.ok(!isCriticalFile('.github/workflows/pr-triage.yml'));
assert.ok(!isCriticalFile('.github/workflows/knip.yml'));
assert.ok(!isCriticalFile('.github/workflows/claude.yml'));
});
it('matches docker/hyperdx/', () => {
assert.ok(isCriticalFile('docker/hyperdx/Dockerfile'));
});
it('does NOT match non-critical API models', () => {
assert.ok(!isCriticalFile('packages/api/src/models/alert.ts'));
assert.ok(!isCriticalFile('packages/api/src/models/dashboard.ts'));
});
it('does NOT match regular app and API files', () => {
assert.ok(!isCriticalFile('packages/app/src/App.tsx'));
assert.ok(!isCriticalFile('packages/api/src/routers/logs.ts'));
});
// Note: isCriticalFile DOES return true for test files under critical paths
// (e.g. packages/api/src/tasks/tests/util.test.ts). The exclusion happens in
// computeSignals, which filters test files out before building criticalFiles.
it('returns true for test files under critical paths (exclusion is in computeSignals)', () => {
assert.ok(isCriticalFile('packages/api/src/tasks/tests/util.test.ts'));
});
});
// ── computeSignals ───────────────────────────────────────────────────────────
describe('computeSignals', () => {
it('separates prod, test, and trivial file line counts', () => {
const pr = makePR('alice', 'feature/foo');
const files = [
makeFile('packages/app/src/Foo.tsx', 20, 5), // prod: 25 lines
makeFile('packages/app/src/__tests__/Foo.test.tsx', 50, 0), // test: 50 lines
makeFile('README.md', 2, 1), // trivial: excluded
];
const s = computeSignals(pr, files);
assert.equal(s.prodFiles.length, 1);
assert.equal(s.prodLines, 25);
assert.equal(s.testLines, 50);
});
it('excludes changeset files from prod counts', () => {
const pr = makePR('alice', 'feature/foo');
const files = [
makeFile('packages/app/src/Foo.tsx', 20, 5),
makeFile('.changeset/witty-foxes-run.md', 5, 0), // trivial
];
const s = computeSignals(pr, files);
assert.equal(s.prodFiles.length, 1);
assert.equal(s.prodLines, 25);
});
it('detects agent branches by prefix', () => {
for (const prefix of ['claude/', 'agent/', 'ai/']) {
const s = computeSignals(makePR('alice', `${prefix}fix-thing`), []);
assert.ok(s.isAgentBranch, `expected isAgentBranch for prefix "${prefix}"`);
}
assert.ok(!computeSignals(makePR('alice', 'feature/normal'), []).isAgentBranch);
});
it('detects bot authors', () => {
assert.ok(computeSignals(makePR('dependabot[bot]', 'dependabot/npm/foo'), []).isBotAuthor);
assert.ok(!computeSignals(makePR('alice', 'feature/foo'), []).isBotAuthor);
});
it('sets allFilesTrivial when every file is trivial', () => {
const files = [makeFile('README.md'), makeFile('yarn.lock')];
assert.ok(computeSignals(makePR('alice', 'docs/update'), files).allFilesTrivial);
});
it('does not set allFilesTrivial for mixed files', () => {
const files = [makeFile('README.md'), makeFile('packages/app/src/Foo.tsx')];
assert.ok(!computeSignals(makePR('alice', 'feat/foo'), files).allFilesTrivial);
});
it('detects cross-layer changes (frontend + backend)', () => {
const files = [
makeFile('packages/app/src/NewFeature.tsx'), // frontend
makeFile('packages/api/src/services/newFeature.ts'), // backend (not models/routers)
];
const s = computeSignals(makePR('alice', 'feat/new'), files);
assert.ok(s.isCrossLayer);
assert.ok(s.touchesFrontend);
assert.ok(s.touchesBackend);
});
it('detects cross-layer changes (backend + shared-utils)', () => {
const files = [
makeFile('packages/api/src/services/foo.ts'),
makeFile('packages/common-utils/src/queryParser.ts'),
];
const s = computeSignals(makePR('alice', 'feat/foo'), files);
assert.ok(s.isCrossLayer);
assert.ok(s.touchesSharedUtils);
});
it('does not flag single-package changes as cross-layer', () => {
const files = [
makeFile('packages/app/src/Foo.tsx'),
makeFile('packages/app/src/Bar.tsx'),
];
assert.ok(!computeSignals(makePR('alice', 'feat/foo'), files).isCrossLayer);
});
it('blocks agent branch from Tier 2 when prod lines exceed threshold', () => {
// 60 prod lines > AGENT_TIER2_MAX_LINES (50)
const s = computeSignals(makePR('alice', 'claude/feature'), [
makeFile('packages/app/src/Foo.tsx', 60, 0),
]);
assert.ok(s.agentBlocksTier2);
});
it('blocks agent branch from Tier 2 when prod file count exceeds threshold', () => {
// 5 prod files > AGENT_TIER2_MAX_PROD_FILES (3)
const files = Array.from({ length: 5 }, (_, i) =>
makeFile(`packages/app/src/File${i}.tsx`, 5, 2)
);
const s = computeSignals(makePR('alice', 'claude/feature'), files);
assert.ok(s.agentBlocksTier2);
});
it('does NOT block agent branch when change is small and focused', () => {
// 16 prod lines, 1 prod file — well under both thresholds
const s = computeSignals(makePR('mikeshi', 'claude/fix-mobile-nav'), [
makeFile('packages/app/src/AppNav.tsx', 11, 5),
]);
assert.ok(!s.agentBlocksTier2);
});
});
// ── determineTier ────────────────────────────────────────────────────────────
describe('determineTier', () => {
describe('Tier 1', () => {
it('bot author', () => {
assert.equal(classify('dependabot[bot]', 'dependabot/npm/foo', [
makeFile('package.json', 5, 3),
]), 1);
});
// package.json is not in TIER1_PATTERNS (it's a production file), but bot
// author short-circuits to Tier 1 before the trivial-file check fires.
it('bot author with package.json (non-trivial file) is still Tier 1', () => {
assert.equal(classify('dependabot[bot]', 'dependabot/npm/lodash', [
makeFile('package.json', 5, 3),
makeFile('packages/api/package.json', 2, 2),
]), 1);
});
it('all trivial files (docs + lock)', () => {
assert.equal(classify('alice', 'docs/update-readme', [
makeFile('README.md', 10, 2),
makeFile('docs/setup.md', 5, 0),
makeFile('yarn.lock', 100, 80),
]), 1);
});
it('changeset-only PR', () => {
assert.equal(classify('alice', 'release/v2.1', [
makeFile('.changeset/witty-foxes-run.md', 4, 0),
]), 1);
});
});
describe('Tier 4', () => {
it('touches auth middleware', () => {
assert.equal(classify('alice', 'fix/auth-bug', [
makeFile('packages/api/src/middleware/auth.ts', 20, 5),
]), 4);
});
it('touches ClickHouse docker config', () => {
assert.equal(classify('alice', 'infra/clickhouse-update', [
makeFile('docker/clickhouse/config.xml', 10, 2),
]), 4);
});
it('touches main.yml or release.yml', () => {
assert.equal(classify('alice', 'ci/add-step', [
makeFile('.github/workflows/main.yml', 15, 3),
]), 4);
assert.equal(classify('alice', 'ci/release-fix', [
makeFile('.github/workflows/release.yml', 8, 2),
]), 4);
});
it('non-critical workflow-only changes are Tier 1 (workflow files are trivial)', () => {
assert.equal(classify('alice', 'ci/add-triage-step', [
makeFile('.github/workflows/pr-triage.yml', 10, 2),
]), 1);
});
it('does NOT flag test files under critical paths as Tier 4', () => {
// e.g. packages/api/src/tasks/tests/util.test.ts should not be critical
assert.equal(classify('alice', 'feat/alert-tests', [
makeFile('packages/api/src/tasks/tests/util.test.ts', 40, 0),
makeFile('packages/api/src/tasks/checkAlerts/tests/checkAlerts.test.ts', 80, 0),
]), 2);
});
it('touches core user/team models', () => {
assert.equal(classify('alice', 'feat/user-fields', [
makeFile('packages/api/src/models/user.ts', 10, 2),
]), 4);
});
it('escalates Tier 3 human branch past 1000 prod lines', () => {
assert.equal(classify('alice', 'feat/huge-refactor', [
makeFile('packages/app/src/BigComponent.tsx', 600, 450), // 1050 lines
]), 4);
});
it('escalates Tier 3 agent branch past 400 prod lines (stricter threshold)', () => {
assert.equal(classify('alice', 'claude/large-feature', [
makeFile('packages/app/src/BigFeature.tsx', 300, 120), // 420 lines
]), 4);
});
});
describe('Tier 2', () => {
it('small single-layer frontend change', () => {
assert.equal(classify('alice', 'fix/button-style', [
makeFile('packages/app/src/components/Button.tsx', 20, 10),
]), 2);
});
it('small single-layer backend change (not models/routers)', () => {
assert.equal(classify('alice', 'fix/service-bug', [
makeFile('packages/api/src/services/logs.ts', 30, 15),
]), 2);
});
it('agent branch small enough to qualify (PR #1431 pattern: 1 file, 16 lines)', () => {
assert.equal(classify('mikeshi', 'claude/fix-mobile-nav', [
makeFile('packages/app/src/AppNav.tsx', 11, 5),
]), 2);
});
it('agent branch exactly at file limit (3 prod files, small lines)', () => {
const files = Array.from({ length: 3 }, (_, i) =>
makeFile(`packages/app/src/File${i}.tsx`, 10, 5)
);
assert.equal(classify('alice', 'claude/small-multi', files), 2);
});
it('human branch at 149 prod lines (just under threshold)', () => {
assert.equal(classify('alice', 'fix/component', [
makeFile('packages/app/src/Foo.tsx', 100, 49), // 149 lines
]), 2);
});
it('agent branch at exactly 49 prod lines qualifies for Tier 2', () => {
assert.equal(classify('alice', 'claude/fix', [
makeFile('packages/app/src/Foo.tsx', 49, 0),
]), 2);
});
});
describe('Tier 3', () => {
it('cross-layer change (frontend + backend)', () => {
assert.equal(classify('alice', 'feat/new-feature', [
makeFile('packages/app/src/NewFeature.tsx', 30, 5),
makeFile('packages/api/src/services/newFeature.ts', 40, 10),
]), 3);
});
it('touches API routes (non-critical)', () => {
assert.equal(classify('alice', 'feat/new-route', [
makeFile('packages/api/src/routers/logs.ts', 30, 5),
]), 3);
});
it('touches API models (non-critical)', () => {
assert.equal(classify('alice', 'feat/model-field', [
makeFile('packages/api/src/models/alert.ts', 20, 3),
]), 3);
});
it('agent branch at exactly 50 prod lines is blocked from Tier 2', () => {
assert.equal(classify('alice', 'claude/feature', [
makeFile('packages/app/src/Foo.tsx', 50, 0), // exactly AGENT_TIER2_MAX_LINES — >= blocks it
]), 3);
});
it('agent branch over prod-line threshold (60 > 50) → Tier 3, not Tier 2', () => {
assert.equal(classify('alice', 'claude/medium-feature', [
makeFile('packages/app/src/Foo.tsx', 60, 0),
]), 3);
});
it('agent branch over file count threshold (4 files) → Tier 3', () => {
const files = Array.from({ length: 4 }, (_, i) =>
makeFile(`packages/app/src/File${i}.tsx`, 10, 5)
);
assert.equal(classify('alice', 'claude/big-feature', files), 3);
});
it('does NOT escalate agent branch at exactly 400 lines (threshold is exclusive)', () => {
// prodLines > threshold, not >=, so 400 stays at Tier 3
assert.equal(classify('alice', 'claude/medium-large', [
makeFile('packages/app/src/Feature.tsx', 200, 200), // exactly 400
]), 3);
});
it('large test additions with small prod change stay Tier 3 (PR #2122 pattern)', () => {
// Alert threshold PR: 1300 total adds but ~1100 are tests
const files = [
makeFile('packages/api/src/services/checkAlerts.ts', 180, 70), // prod: 250 lines
makeFile('packages/api/src/__tests__/checkAlerts.test.ts', 1100, 0), // test: excluded
];
// 250 prod lines > TIER2_MAX_LINES (150) → Tier 3, not Tier 4
assert.equal(classify('alice', 'feat/alert-thresholds', files), 3);
});
it('human branch at exactly 150 prod lines is Tier 3, not Tier 2', () => {
assert.equal(classify('alice', 'fix/component', [
makeFile('packages/app/src/Foo.tsx', 100, 50), // exactly TIER2_MAX_LINES — < is exclusive
]), 3);
});
it('does NOT escalate human branch at exactly 1000 prod lines', () => {
assert.equal(classify('alice', 'feat/medium-large', [
makeFile('packages/app/src/Feature.tsx', 500, 500), // exactly 1000
]), 3);
});
});
});
// ── buildTierComment ─────────────────────────────────────────────────────────
describe('buildTierComment', () => {
/** Build a signal object with sensible defaults, overrideable per test */
function makeSignals(overrides = {}) {
return {
author: 'alice',
branchName: 'feature/foo',
prodFiles: [makeFile('packages/app/src/Foo.tsx')],
prodLines: 50,
testLines: 0,
criticalFiles: [],
isAgentBranch: false,
isBotAuthor: false,
allFilesTrivial: false,
touchesApiModels: false,
touchesFrontend: true,
touchesBackend: false,
touchesSharedUtils: false,
isCrossLayer: false,
agentBlocksTier2: false,
...overrides,
};
}
it('always includes the pr-triage sentinel marker', () => {
assert.ok(buildTierComment(2, makeSignals()).includes('<!-- pr-triage -->'));
});
it('includes the correct headline for each tier', () => {
assert.ok(buildTierComment(1, makeSignals()).includes('Tier 1'));
assert.ok(buildTierComment(2, makeSignals()).includes('Tier 2'));
assert.ok(buildTierComment(3, makeSignals()).includes('Tier 3'));
assert.ok(buildTierComment(4, makeSignals()).includes('Tier 4'));
});
it('includes override instructions with the correct tier label', () => {
const body = buildTierComment(3, makeSignals());
assert.ok(body.includes('review/tier-3'));
assert.ok(body.includes('Manual overrides are preserved'));
});
it('lists critical files when present', () => {
const signals = makeSignals({
criticalFiles: [makeFile('packages/api/src/middleware/auth.ts')],
});
const body = buildTierComment(4, signals);
assert.ok(body.includes('Critical-path files'));
assert.ok(body.includes('auth.ts'));
});
it('explains cross-layer trigger with which layers are involved', () => {
const signals = makeSignals({
isCrossLayer: true,
touchesFrontend: true,
touchesBackend: true,
touchesSharedUtils: false,
});
const body = buildTierComment(3, signals);
assert.ok(body.includes('Cross-layer change'));
assert.ok(body.includes('packages/app'));
assert.ok(body.includes('packages/api'));
});
it('explains API model/route trigger', () => {
const body = buildTierComment(3, makeSignals({ touchesApiModels: true }));
assert.ok(body.includes('API routes or data models'));
});
it('explains agent branch bump to Tier 3', () => {
const signals = makeSignals({
isAgentBranch: true,
agentBlocksTier2: true,
branchName: 'claude/big-feature',
prodLines: 80,
prodFiles: Array.from({ length: 5 }, (_, i) => makeFile(`packages/app/src/File${i}.tsx`)),
});
const body = buildTierComment(3, signals);
assert.ok(body.includes('bumped to Tier 3'));
});
it('notes when agent branch is small enough for Tier 2', () => {
const signals = makeSignals({
isAgentBranch: true,
agentBlocksTier2: false,
branchName: 'claude/tiny-fix',
});
const body = buildTierComment(2, signals);
assert.ok(body.includes('small enough to qualify for Tier 2'));
});
it('shows test line count in stats when non-zero', () => {
const body = buildTierComment(2, makeSignals({ testLines: 200 }));
assert.ok(body.includes('200 in test files'));
});
it('omits test line note when testLines is 0', () => {
const body = buildTierComment(2, makeSignals({ testLines: 0 }));
assert.ok(!body.includes('test files'));
});
it('includes a catch-all trigger for standard Tier 3 PRs with no specific signals', () => {
const body = buildTierComment(3, makeSignals());
assert.ok(body.includes('Standard feature/fix'));
});
it('includes bot-author trigger for Tier 1 bot PRs', () => {
const body = buildTierComment(1, makeSignals({ isBotAuthor: true, author: 'dependabot[bot]' }));
assert.ok(body.includes('Bot author'));
});
});

257
.github/scripts/pr-triage-classify.js vendored Normal file
View file

@ -0,0 +1,257 @@
'use strict';
// ── File classification patterns ─────────────────────────────────────────────
const TIER4_PATTERNS = [
/^packages\/api\/src\/middleware\/auth/,
/^packages\/api\/src\/routers\/api\/me\./,
/^packages\/api\/src\/routers\/api\/team\./,
/^packages\/api\/src\/routers\/external-api\//,
/^packages\/api\/src\/models\/(user|team|teamInvite)\./,
/^packages\/api\/src\/config\./,
/^packages\/api\/src\/tasks\//,
/^packages\/otel-collector\//,
/^docker\/otel-collector\//,
/^docker\/clickhouse\//,
/^docker\/hyperdx\//,
/^\.github\/workflows\/(main|release)\.yml$/,
];
const TIER1_PATTERNS = [
/\.(md|txt|png|jpg|jpeg|gif|svg|ico)$/i,
/^yarn\.lock$/,
/^package-lock\.json$/,
/^\.yarnrc\.yml$/,
/^\.github\/images\//,
/^\.env\.example$/,
/^\.changeset\//, // version-bump config files; no functional code
/^\.github\/scripts\//, // GitHub Actions scripts; not application code
/^\.github\/workflows\//, // workflow files (main.yml/release.yml still caught by TIER4_PATTERNS)
];
const TEST_FILE_PATTERNS = [
/\/__tests__\//,
/\.test\.[jt]sx?$/,
/\.spec\.[jt]sx?$/,
/^packages\/app\/tests\//,
];
// ── Thresholds (all line counts exclude test and trivial files) ───────────────
const TIER2_MAX_LINES = 150; // max prod lines eligible for Tier 2
const TIER4_ESCALATION_HUMAN = 1000; // Tier 3 → 4 for human branches
const TIER4_ESCALATION_AGENT = 400; // Tier 3 → 4 for agent branches (stricter)
// Agent branches can reach Tier 2 only for very small, focused changes
const AGENT_TIER2_MAX_LINES = 50;
const AGENT_TIER2_MAX_PROD_FILES = 3;
// ── Other constants ──────────────────────────────────────────────────────────
const BOT_AUTHORS = ['dependabot', 'dependabot[bot]'];
const AGENT_BRANCH_PREFIXES = ['claude/', 'agent/', 'ai/'];
const TIER_LABELS = {
1: { name: 'review/tier-1', color: '0E8A16', description: 'Trivial — auto-merge candidate once CI passes' },
2: { name: 'review/tier-2', color: '1D76DB', description: 'Low risk — AI review + quick human skim' },
3: { name: 'review/tier-3', color: 'E4E669', description: 'Standard — full human review required' },
4: { name: 'review/tier-4', color: 'B60205', description: 'Critical — deep review + domain expert sign-off' },
};
const TIER_INFO = {
1: {
emoji: '🟢',
headline: 'Tier 1 — Trivial',
detail: 'Docs, images, lock files, or a dependency bump. No functional code changes detected.',
process: 'Auto-merge once CI passes. No human review required.',
sla: 'Resolves automatically.',
},
2: {
emoji: '🔵',
headline: 'Tier 2 — Low Risk',
detail: 'Small, isolated change with no API route or data model modifications.',
process: 'AI review + quick human skim (target: 515 min). Reviewer validates AI assessment and checks for domain-specific concerns.',
sla: 'Resolve within 4 business hours.',
},
3: {
emoji: '🟡',
headline: 'Tier 3 — Standard',
detail: 'Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.',
process: 'Full human review — logic, architecture, edge cases.',
sla: 'First-pass feedback within 1 business day.',
},
4: {
emoji: '🔴',
headline: 'Tier 4 — Critical',
detail: 'Touches auth, data models, config, tasks, OTel pipeline, ClickHouse, or CI/CD.',
process: 'Deep review from a domain expert. Synchronous walkthrough may be required.',
sla: 'Schedule synchronous review within 2 business days.',
},
};
// ── File classification helpers ──────────────────────────────────────────────
const isTestFile = f => TEST_FILE_PATTERNS.some(p => p.test(f));
const isTrivialFile = f => TIER1_PATTERNS.some(p => p.test(f));
const isCriticalFile = f => TIER4_PATTERNS.some(p => p.test(f));
// ── Signal computation ───────────────────────────────────────────────────────
// Returns a flat object of all facts needed for tier determination and comment
// generation. All derived from PR metadata + file list — no GitHub API calls.
//
// @param {object} pr - GitHub PR object: { user: { login }, head: { ref } }
// @param {Array} filesRes - GitHub files array: [{ filename, additions, deletions }]
function computeSignals(pr, filesRes) {
const author = pr.user.login;
const branchName = pr.head.ref;
const testFiles = filesRes.filter(f => isTestFile(f.filename));
const prodFiles = filesRes.filter(f => !isTestFile(f.filename) && !isTrivialFile(f.filename));
const criticalFiles = filesRes.filter(f => isCriticalFile(f.filename) && !isTestFile(f.filename));
const prodLines = prodFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0);
const testLines = testFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0);
const isAgentBranch = AGENT_BRANCH_PREFIXES.some(p => branchName.startsWith(p));
const isBotAuthor = BOT_AUTHORS.includes(author);
const allFilesTrivial = filesRes.length > 0 && filesRes.every(f => isTrivialFile(f.filename));
// Blocks Tier 2 — API models and routes carry implicit cross-cutting risk
const touchesApiModels = prodFiles.some(f =>
f.filename.startsWith('packages/api/src/models/') ||
f.filename.startsWith('packages/api/src/routers/')
);
// Cross-layer: production changes spanning multiple monorepo packages
const touchesFrontend = prodFiles.some(f => f.filename.startsWith('packages/app/'));
const touchesBackend = prodFiles.some(f => f.filename.startsWith('packages/api/'));
const touchesSharedUtils = prodFiles.some(f => f.filename.startsWith('packages/common-utils/'));
const isCrossLayer = [touchesFrontend, touchesBackend, touchesSharedUtils].filter(Boolean).length >= 2;
// Agent branches can reach Tier 2 only when the change is very small and focused
const agentBlocksTier2 = isAgentBranch &&
(prodLines >= AGENT_TIER2_MAX_LINES || prodFiles.length > AGENT_TIER2_MAX_PROD_FILES);
return {
author, branchName,
prodFiles, prodLines, testLines, criticalFiles,
isAgentBranch, isBotAuthor, allFilesTrivial,
touchesApiModels, touchesFrontend, touchesBackend, touchesSharedUtils,
isCrossLayer, agentBlocksTier2,
};
}
// ── Tier determination ───────────────────────────────────────────────────────
// @param {object} signals - output of computeSignals()
// @returns {number} tier - 1 | 2 | 3 | 4
function determineTier(signals) {
const {
criticalFiles, isBotAuthor, allFilesTrivial,
prodLines, touchesApiModels, isCrossLayer, agentBlocksTier2, isAgentBranch,
} = signals;
// Tier 4: touches critical infrastructure (auth, config, pipeline, CI/CD)
if (criticalFiles.length > 0) return 4;
// Tier 1: bot-authored or only docs/images/lock files changed
if (isBotAuthor || allFilesTrivial) return 1;
// Tier 2: small, isolated, single-layer change
// Agent branches qualify when the change is very small and focused
// (agentBlocksTier2 is false when under AGENT_TIER2_MAX_LINES / MAX_PROD_FILES)
const qualifiesForTier2 =
prodLines < TIER2_MAX_LINES &&
!touchesApiModels &&
!isCrossLayer &&
!agentBlocksTier2;
if (qualifiesForTier2) return 2;
// Tier 3: everything else — escalate very large diffs to Tier 4
const sizeThreshold = isAgentBranch ? TIER4_ESCALATION_AGENT : TIER4_ESCALATION_HUMAN;
return prodLines > sizeThreshold ? 4 : 3;
}
// ── Comment generation ───────────────────────────────────────────────────────
// @param {number} tier - 1 | 2 | 3 | 4
// @param {object} signals - output of computeSignals()
// @returns {string} - Markdown comment body
function buildTierComment(tier, signals) {
const {
author, branchName,
prodFiles, prodLines, testLines, criticalFiles,
isAgentBranch, isBotAuthor, allFilesTrivial,
touchesApiModels, touchesFrontend, touchesBackend, touchesSharedUtils,
isCrossLayer, agentBlocksTier2,
} = signals;
const info = TIER_INFO[tier];
const sizeThreshold = isAgentBranch ? TIER4_ESCALATION_AGENT : TIER4_ESCALATION_HUMAN;
// Primary triggers — the specific reasons this tier was assigned
const triggers = [];
if (criticalFiles.length > 0) {
triggers.push(`**Critical-path files** (${criticalFiles.length}):\n${criticalFiles.map(f => ` - \`${f.filename}\``).join('\n')}`);
}
if (tier === 4 && prodLines > sizeThreshold && criticalFiles.length === 0) {
triggers.push(`**Large diff**: ${prodLines} production lines changed (threshold: ${sizeThreshold})`);
}
if (isBotAuthor) triggers.push(`**Bot author**: \`${author}\``);
if (allFilesTrivial && !isBotAuthor) triggers.push('**All files are docs / images / lock files**');
if (isCrossLayer) {
const layers = [
touchesFrontend && 'frontend (`packages/app`)',
touchesBackend && 'backend (`packages/api`)',
touchesSharedUtils && 'shared utils (`packages/common-utils`)',
].filter(Boolean);
triggers.push(`**Cross-layer change**: touches ${layers.join(' + ')}`);
}
if (touchesApiModels && criticalFiles.length === 0) {
triggers.push('**Touches API routes or data models** — hidden complexity risk');
}
if (isAgentBranch && agentBlocksTier2 && tier <= 3) {
triggers.push(`**Agent-generated branch** (\`${branchName}\`) with ${prodLines} prod lines across ${prodFiles.length} files — bumped to Tier 3 for mandatory human review`);
}
if (triggers.length === 0) {
triggers.push('**Standard feature/fix** — introduces new logic or modifies core functionality');
}
// Informational context — didn't drive the tier on their own
const contextSignals = [];
if (isAgentBranch && !agentBlocksTier2 && tier === 2) {
contextSignals.push(`agent branch (\`${branchName}\`) — change small enough to qualify for Tier 2`);
} else if (isAgentBranch && tier === 4) {
contextSignals.push(`agent branch (\`${branchName}\`)`);
}
const triggerSection = `\n**Why this tier:**\n${triggers.map(t => `- ${t}`).join('\n')}`;
const contextSection = contextSignals.length > 0
? `\n**Additional context:** ${contextSignals.join(', ')}`
: '';
return [
'<!-- pr-triage -->',
`## ${info.emoji} ${info.headline}`,
'',
info.detail,
triggerSection,
contextSection,
'',
`**Review process**: ${info.process}`,
`**SLA**: ${info.sla}`,
'',
'<details><summary>Stats</summary>',
'',
`- Production files changed: ${prodFiles.length}`,
`- Production lines changed: ${prodLines}${testLines > 0 ? ` (+ ${testLines} in test files, excluded from tier calculation)` : ''}`,
`- Branch: \`${branchName}\``,
`- Author: ${author}`,
'',
'</details>',
'',
`> To override this classification, remove the \`${TIER_LABELS[tier].name}\` label and apply a different \`review/tier-*\` label. Manual overrides are preserved on subsequent pushes.`,
].join('\n');
}
module.exports = {
// Constants needed by the orchestration script
TIER_LABELS, TIER_INFO,
// Pure functions
isTestFile, isTrivialFile, isCriticalFile,
computeSignals, determineTier, buildTierComment,
};

123
.github/scripts/pr-triage.js vendored Normal file
View file

@ -0,0 +1,123 @@
'use strict';
// Entry point for actions/github-script@v7 via script-path.
// Pure classification logic lives in pr-triage-classify.js so it can be
// unit-tested without GitHub API machinery.
const {
TIER_LABELS,
computeSignals, determineTier, buildTierComment,
} = require('./pr-triage-classify');
module.exports = async ({ github, context }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;
// ── Determine which PRs to process ──────────────────────────────────────
let prNumbers;
if (context.eventName === 'workflow_dispatch') {
// Use context.payload.inputs to avoid script-injection via template interpolation
const input = (context.payload.inputs?.pr_number ?? '').trim();
if (input !== '') {
const num = Number(input);
if (!Number.isInteger(num) || num <= 0) {
throw new Error(`Invalid PR number: "${input}"`);
}
prNumbers = [num];
} else {
const openPRs = await github.paginate(
github.rest.pulls.list,
{ owner, repo, state: 'open', per_page: 100 }
);
prNumbers = openPRs.map(pr => pr.number);
console.log(`Bulk triage: found ${prNumbers.length} open PRs`);
}
} else {
prNumbers = [context.payload.pull_request.number];
}
// ── Ensure tier labels exist (once, before the loop) ────────────────────
const repoLabels = await github.paginate(
github.rest.issues.listLabelsForRepo,
{ owner, repo, per_page: 100 }
);
const repoLabelNames = new Set(repoLabels.map(l => l.name));
for (const label of Object.values(TIER_LABELS)) {
if (!repoLabelNames.has(label.name)) {
await github.rest.issues.createLabel({ owner, repo, ...label });
repoLabelNames.add(label.name);
}
}
// ── Classify a single PR ─────────────────────────────────────────────────
async function classifyPR(prNumber) {
const filesRes = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number: prNumber, per_page: 100 }
);
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: prNumber });
const currentLabelNames = new Set(currentLabels.map(l => l.name));
// Skip drafts (bulk mode; PR events already filter these via the job condition)
if (pr.draft) {
console.log(`PR #${prNumber}: skipping draft`);
return;
}
// Respect manual tier overrides — don't overwrite labels applied by humans
const existingTierLabel = currentLabels.find(l => l.name.startsWith('review/tier-'));
if (existingTierLabel) {
const events = await github.paginate(
github.rest.issues.listEvents,
{ owner, repo, issue_number: prNumber, per_page: 100 }
);
const lastLabelEvent = events
.filter(e => e.event === 'labeled' && e.label?.name === existingTierLabel.name)
.pop();
if (lastLabelEvent && lastLabelEvent.actor.type !== 'Bot') {
console.log(`PR #${prNumber}: tier manually set to ${existingTierLabel.name} by ${lastLabelEvent.actor.login} — skipping`);
return;
}
}
const signals = computeSignals(pr, filesRes);
const tier = determineTier(signals);
const body = buildTierComment(tier, signals);
// Apply the tier label (remove any stale tier label first)
for (const label of currentLabels) {
if (label.name.startsWith('review/tier-') && label.name !== TIER_LABELS[tier].name) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label.name });
}
}
if (!currentLabelNames.has(TIER_LABELS[tier].name)) {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [TIER_LABELS[tier].name] });
}
// Post or update the triage comment
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 }
);
const existingComment = comments.find(
c => c.user.login === 'github-actions[bot]' && c.body.includes('<!-- pr-triage -->')
);
if (existingComment) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existingComment.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}
console.log(`PR #${prNumber}: Tier ${tier} (${signals.prodLines} prod lines, ${signals.prodFiles.length} prod files, ${signals.testLines} test lines)`);
}
// ── Process all target PRs ───────────────────────────────────────────────
for (const prNumber of prNumbers) {
try {
await classifyPR(prNumber);
} catch (err) {
console.error(`PR #${prNumber}: classification failed — ${err.message}`);
}
}
};

View file

@ -51,4 +51,4 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
use_sticky_comment: 'true'
include_fix_links: 'true'
claude_args: '--max-turns 20'
claude_args: '--max-turns 60'

View file

@ -47,7 +47,7 @@ jobs:
- name: Run unit tests
run: make ci-unit
integration:
timeout-minutes: 8
timeout-minutes: 16
runs-on: ubuntu-24.04
steps:
- name: Checkout
@ -93,7 +93,7 @@ jobs:
working-directory: ./packages/otel-collector
run: go test ./...
otel-smoke-test:
timeout-minutes: 8
timeout-minutes: 16
runs-on: ubuntu-24.04
steps:
- name: Checkout

View file

@ -2,6 +2,7 @@ name: PR Triage
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
@ -16,246 +17,39 @@ permissions:
pull-requests: write
issues: write
concurrency:
group:
${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id
}}
cancel-in-progress: true
jobs:
test:
name: Test triage logic
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- run: node --test .github/scripts/__tests__/pr-triage-classify.test.js
classify:
name: Classify PR risk tier
needs: test
runs-on: ubuntu-24.04
timeout-minutes: 8
# For pull_request events skip drafts; workflow_dispatch always runs
if:
${{ github.event_name == 'workflow_dispatch' ||
!github.event.pull_request.draft }}
steps:
- uses: actions/checkout@v4
- name: Classify and label PR(s)
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
// ── Determine which PRs to process ──────────────────────────────
let prNumbers;
if (context.eventName === 'workflow_dispatch') {
// Use context.payload.inputs to avoid script-injection via template interpolation
const input = (context.payload.inputs?.pr_number ?? '').trim();
if (input && input !== '') {
prNumbers = [Number(input)];
} else {
const openPRs = await github.paginate(
github.rest.pulls.list,
{ owner, repo, state: 'open', per_page: 100 }
);
prNumbers = openPRs.map(pr => pr.number);
console.log(`Bulk triage: found ${prNumbers.length} open PRs`);
}
} else {
prNumbers = [context.payload.pull_request.number];
}
// ── Shared constants ─────────────────────────────────────────────
const TIER4_PATTERNS = [
/^packages\/api\/src\/middleware\/auth/,
/^packages\/api\/src\/routers\/api\/me\./,
/^packages\/api\/src\/routers\/api\/team\./,
/^packages\/api\/src\/routers\/external-api\//,
/^packages\/api\/src\/models\/(user|team|teamInvite)\./,
/^packages\/api\/src\/config\./,
/^packages\/api\/src\/tasks\//,
/^packages\/otel-collector\//,
/^docker\/otel-collector\//,
/^docker\/clickhouse\//,
/^\.github\/workflows\//,
];
const TIER1_PATTERNS = [
/\.(md|txt|png|jpg|jpeg|gif|svg|ico)$/i,
/^yarn\.lock$/,
/^package-lock\.json$/,
/^\.yarnrc\.yml$/,
/^\.github\/images\//,
/^\.env\.example$/,
];
const BOT_AUTHORS = ['dependabot', 'dependabot[bot]'];
const AGENT_BRANCH_PREFIXES = ['claude/', 'agent/', 'ai/'];
const TIER_LABELS = {
1: { name: 'review/tier-1', color: '0E8A16', description: 'Trivial — auto-merge candidate once CI passes' },
2: { name: 'review/tier-2', color: '1D76DB', description: 'Low risk — AI review + quick human skim' },
3: { name: 'review/tier-3', color: 'E4E669', description: 'Standard — full human review required' },
4: { name: 'review/tier-4', color: 'B60205', description: 'Critical — deep review + domain expert sign-off' },
};
const TIER_INFO = {
1: {
emoji: '🟢',
headline: 'Tier 1 — Trivial',
detail: 'Docs, images, lock files, or a dependency bump. No functional code changes detected.',
process: 'Auto-merge once CI passes. No human review required.',
sla: 'Resolves automatically.',
},
2: {
emoji: '🔵',
headline: 'Tier 2 — Low Risk',
detail: 'Small, isolated change with no API route or data model modifications.',
process: 'AI review + quick human skim (target: 515 min). Reviewer validates AI assessment and checks for domain-specific concerns.',
sla: 'Resolve within 4 business hours.',
},
3: {
emoji: '🟡',
headline: 'Tier 3 — Standard',
detail: 'Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.',
process: 'Full human review — logic, architecture, edge cases.',
sla: 'First-pass feedback within 1 business day.',
},
4: {
emoji: '🔴',
headline: 'Tier 4 — Critical',
detail: 'Touches auth, data models, config, tasks, OTel pipeline, ClickHouse, or CI/CD.',
process: 'Deep review from a domain expert. Synchronous walkthrough may be required.',
sla: 'Schedule synchronous review within 2 business days.',
},
};
// ── Ensure tier labels exist (once, before the loop) ─────────────
const repoLabels = await github.paginate(
github.rest.issues.listLabelsForRepo,
{ owner, repo, per_page: 100 }
);
const repoLabelNames = new Set(repoLabels.map(l => l.name));
for (const label of Object.values(TIER_LABELS)) {
if (!repoLabelNames.has(label.name)) {
await github.rest.issues.createLabel({ owner, repo, ...label });
repoLabelNames.add(label.name);
}
}
// ── Classify a single PR ─────────────────────────────────────────
async function classifyPR(prNumber) {
// Fetch changed files
const filesRes = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number: prNumber, per_page: 100 }
);
const files = filesRes.map(f => f.filename);
const linesChanged = filesRes.reduce((sum, f) => sum + f.additions + f.deletions, 0);
// Fetch PR metadata
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const author = pr.user.login;
const branchName = pr.head.ref;
// Skip drafts when running in bulk mode
if (pr.draft) {
console.log(`Skipping PR #${prNumber}: draft`);
return;
}
// Check for manual tier override — if a human last applied the label, respect it
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: prNumber });
const existingTierLabel = currentLabels.find(l => l.name.startsWith('review/tier-'));
if (existingTierLabel) {
const events = await github.paginate(
github.rest.issues.listEvents,
{ owner, repo, issue_number: prNumber, per_page: 100 }
);
const lastLabelEvent = events
.filter(e => e.event === 'labeled' && e.label?.name === existingTierLabel.name)
.pop();
if (lastLabelEvent && lastLabelEvent.actor.type !== 'Bot') {
console.log(`PR #${prNumber}: tier manually set to ${existingTierLabel.name} by ${lastLabelEvent.actor.login} — skipping`);
return;
}
}
// Classify
const isTier4 = files.some(f => TIER4_PATTERNS.some(p => p.test(f)));
const isTrivialAuthor = BOT_AUTHORS.includes(author);
const allFilesTrivial = files.length > 0 && files.every(f => TIER1_PATTERNS.some(p => p.test(f)));
const isTier1 = isTrivialAuthor || allFilesTrivial;
const isAgentBranch = AGENT_BRANCH_PREFIXES.some(p => branchName.startsWith(p));
const touchesApiModels = files.some(f =>
f.startsWith('packages/api/src/models/') || f.startsWith('packages/api/src/routers/')
);
const isSmallDiff = linesChanged < 100;
// Agent branches are bumped to Tier 3 regardless of size to ensure human review
const isTier2 = !isTier4 && !isTier1 && isSmallDiff && !touchesApiModels && !isAgentBranch;
let tier;
if (isTier4) tier = 4;
else if (isTier1) tier = 1;
else if (isTier2) tier = 2;
else tier = 3;
// Escalate very large non-critical PRs to Tier 4; this also applies to agent
// branches that were bumped to Tier 3 above — a 400+ line agent-generated change
// warrants deep review regardless of which files it touches.
if (tier === 3 && linesChanged > 400) tier = 4;
// Apply label
for (const existing of currentLabels) {
if (existing.name.startsWith('review/tier-') && existing.name !== TIER_LABELS[tier].name) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: existing.name });
}
}
if (!currentLabels.find(l => l.name === TIER_LABELS[tier].name)) {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [TIER_LABELS[tier].name] });
}
// Build comment body
const info = TIER_INFO[tier];
const signals = [];
if (isTier4) signals.push('critical-path files detected');
if (isAgentBranch) signals.push(`agent branch (\`${branchName}\`)`);
if (linesChanged > 400) signals.push(`large diff (${linesChanged} lines changed)`);
if (isTrivialAuthor) signals.push(`bot author (${author})`);
if (allFilesTrivial && !isTrivialAuthor) signals.push('all files are docs/images/lock files');
if (touchesApiModels) signals.push('API routes or data models changed');
const signalList = signals.length > 0 ? `\n**Signals**: ${signals.join(', ')}` : '';
const body = [
'<!-- pr-triage -->',
`## ${info.emoji} ${info.headline}`,
'',
info.detail,
signalList,
'',
`**Review process**: ${info.process}`,
`**SLA**: ${info.sla}`,
'',
`<details><summary>Stats</summary>`,
'',
`- Files changed: ${files.length}`,
`- Lines changed: ${linesChanged}`,
`- Branch: \`${branchName}\``,
`- Author: ${author}`,
'',
'</details>',
'',
`> To override this classification, remove the \`${TIER_LABELS[tier].name}\` label and apply a different \`review/tier-*\` label. Manual overrides are preserved on subsequent pushes.`,
].join('\n');
// Post or update the single triage comment
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 }
);
const existing = comments.find(c => c.user.login === 'github-actions[bot]' && c.body.includes('<!-- pr-triage -->'));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}
console.log(`PR #${prNumber}: Tier ${tier} (${linesChanged} lines, ${files.length} files)`);
}
// ── Process all target PRs ───────────────────────────────────────
for (const prNumber of prNumbers) {
try {
await classifyPR(prNumber);
} catch (err) {
console.error(`PR #${prNumber}: classification failed — ${err.message}`);
}
}
const path = require('path');
const run = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-triage.js'));
await run({ github, context });

View file

@ -418,6 +418,106 @@ jobs:
"${IMAGE}:${VERSION}-arm64"
done
# ---------------------------------------------------------------------------
# CLI compile standalone binaries and upload as GitHub Release assets
# npm publishing is handled by changesets in the check_changesets job above.
# This job only compiles platform-specific binaries and creates a GH Release.
# ---------------------------------------------------------------------------
release-cli:
name: Release CLI Binaries
needs: [check_changesets, check_version]
if: needs.check_version.outputs.should_release == 'true'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache-dependency-path: 'yarn.lock'
cache: 'yarn'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.11'
- name: Install dependencies
run: yarn install
- name: Build common-utils
run: make ci-build
- name: Get CLI version
id: cli_version
run: |
CLI_VERSION=$(node -p "require('./packages/cli/package.json').version")
echo "version=${CLI_VERSION}" >> $GITHUB_OUTPUT
echo "CLI version: ${CLI_VERSION}"
- name: Check if CLI release already exists
id: check_cli_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "cli-v${{ steps.cli_version.outputs.version }}" > /dev/null 2>&1; then
echo "Release cli-v${{ steps.cli_version.outputs.version }} already exists. Skipping."
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "Release does not exist. Proceeding."
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Compile CLI binaries
if: steps.check_cli_release.outputs.exists == 'false'
working-directory: packages/cli
run: |
yarn compile:linux
yarn compile:macos
yarn compile:macos-x64
- name: Create GitHub Release
if: steps.check_cli_release.outputs.exists == 'false'
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
tag_name: cli-v${{ steps.cli_version.outputs.version }}
name: '@hyperdx/cli v${{ steps.cli_version.outputs.version }}'
body: |
## @hyperdx/cli v${{ steps.cli_version.outputs.version }}
### Installation
**npm (recommended):**
```bash
npm install -g @hyperdx/cli
```
**Or run directly with npx:**
```bash
npx @hyperdx/cli tui -s <your-hyperdx-api-url>
```
**Manual download (standalone binary, no Node.js required):**
```bash
# macOS Apple Silicon
curl -L https://github.com/hyperdxio/hyperdx/releases/download/cli-v${{ steps.cli_version.outputs.version }}/hdx-darwin-arm64 -o hdx
# macOS Intel
curl -L https://github.com/hyperdxio/hyperdx/releases/download/cli-v${{ steps.cli_version.outputs.version }}/hdx-darwin-x64 -o hdx
# Linux x64
curl -L https://github.com/hyperdxio/hyperdx/releases/download/cli-v${{ steps.cli_version.outputs.version }}/hdx-linux-x64 -o hdx
chmod +x hdx && sudo mv hdx /usr/local/bin/
```
### Usage
```bash
hdx auth login -s <your-hyperdx-api-url>
hdx tui
```
draft: false
prerelease: false
files: |
packages/cli/dist/hdx-linux-x64
packages/cli/dist/hdx-darwin-arm64
packages/cli/dist/hdx-darwin-x64
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ---------------------------------------------------------------------------
# Downstream notifications
# ---------------------------------------------------------------------------
@ -553,6 +653,7 @@ jobs:
publish-otel-collector,
publish-local,
publish-all-in-one,
release-cli,
notify_helm_charts,
notify_ch,
notify_clickhouse_clickstack,

View file

@ -229,6 +229,49 @@ formatting checks pass. Fix any issues before creating the commit.
manual intervention rather than guessing. A wrong guess silently breaks
things; asking is always cheaper than debugging later.
## Cursor Cloud specific instructions
### Docker requirement
Docker must be installed and running before starting the dev stack or running
integration/E2E tests. The VM update script handles `yarn install` and
`yarn build:common-utils`, but Docker daemon startup is a prerequisite that must
already be available.
### Starting the dev stack
`yarn dev` uses `sh -c` to source `scripts/dev-env.sh`, which contains
bash-specific syntax (`BASH_SOURCE`). On systems where `/bin/sh` is `dash`
(e.g. Ubuntu), this fails with "Bad substitution". Work around it by running
with bash directly:
```bash
bash -c 'export PATH="/workspace/node_modules/.bin:$PATH" && source ./scripts/dev-env.sh && yarn build:common-utils && dotenvx run --convention=nextjs -- docker compose -p "$HDX_DEV_PROJECT" -f docker-compose.dev.yml up -d && yarn app:dev'
```
Port isolation assigns a slot based on the worktree directory name. In the
default `/workspace` directory, the slot is **76**, so services are at:
- **App**: http://localhost:30276
- **API**: http://localhost:30176
- **ClickHouse**: http://localhost:30576
- **MongoDB**: localhost:30476
### Key commands reference
See `AGENTS.md` above and `agent_docs/development.md` for the full command
reference. Quick summary:
- `make ci-lint` — lint + TypeScript type check
- `make ci-unit` — unit tests (all packages)
- `make dev-int FILE=<name>` — integration tests (spins up Docker services)
- `make dev-e2e FILE=<name>` — E2E tests (Playwright)
### First-time registration
When the dev stack starts fresh (empty MongoDB), the app shows a registration
page. Create any account to get started — no external auth provider is needed.
---
_Need more details? Check the `agent_docs/` directory or ask which documentation

View file

@ -125,7 +125,11 @@ yarn dev:unit
## AI-Assisted Development
The repo ships with configuration for AI coding assistants that enables interactive browser-based E2E test generation and debugging via the [Playwright MCP server](https://github.com/microsoft/playwright-mcp).
HyperDX includes an [MCP server](https://modelcontextprotocol.io/) that lets AI assistants query observability data, manage dashboards, and
explore data sources. See [MCP.md](/MCP.md) for setup instructions.
The repo also ships with configuration for AI coding assistants that enables interactive browser-based E2E test generation and debugging via
the [Playwright MCP server](https://github.com/microsoft/playwright-mcp).
### Claude Code

89
MCP.md Normal file
View file

@ -0,0 +1,89 @@
# HyperDX MCP Server
HyperDX exposes a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that lets AI assistants query your observability
data, manage dashboards, and explore data sources directly.
## Prerequisites
- A running HyperDX instance (see [CONTRIBUTING.md](/CONTRIBUTING.md) for local development setup, or [DEPLOY.md](/DEPLOY.md) for
self-hosted deployment)
- A **Personal API Access Key** — find yours in the HyperDX UI under **Team Settings > API Keys > Personal API Access Key**
## Endpoint
The MCP server is available at the `/api/mcp` path on your HyperDX instance. For local development this is:
```
http://localhost:8080/api/mcp
```
Replace `localhost:8080` with your instance's host and port if you've customized the defaults.
## Connecting an MCP Client
The MCP server uses the **Streamable HTTP** transport with Bearer token authentication. In the examples below, replace `<your-hyperdx-url>`
with your instance URL (e.g. `http://localhost:8080`).
### Claude Code
```bash
claude mcp add --transport http hyperdx <your-hyperdx-url>/api/mcp \
--header "Authorization: Bearer <your-personal-access-key>"
```
### OpenCode
```bash
opencode mcp add --transport http hyperdx <your-hyperdx-url>/api/mcp \
--header "Authorization: Bearer <your-personal-access-key>"
```
### Cursor
Add the following to `.cursor/mcp.json` in your project (or your global Cursor settings):
```json
{
"mcpServers": {
"hyperdx": {
"url": "<your-hyperdx-url>/api/mcp",
"headers": {
"Authorization": "Bearer <your-personal-access-key>"
}
}
}
}
```
### MCP Inspector
The MCP Inspector is useful for interactively testing and debugging the server.
```bash
cd packages/api && yarn dev:mcp
```
Then configure the inspector:
1. **Transport Type:** Streamable HTTP
2. **URL:** `<your-hyperdx-url>/api/mcp`
3. **Authentication:** Header `Authorization` with value `Bearer <your-personal-access-key>`
4. Click **Connect**
### Other Clients
Any MCP client that supports Streamable HTTP transport can connect. Configure it with:
- **URL:** `<your-hyperdx-url>/api/mcp`
- **Header:** `Authorization: Bearer <your-personal-access-key>`
## Available Tools
| Tool | Description |
| -------------------------- | -------------------------------------------------------------------------------------------- |
| `hyperdx_list_sources` | List all data sources and database connections, including column schemas and attribute keys |
| `hyperdx_query` | Query observability data (logs, metrics, traces) using builder mode, search mode, or raw SQL |
| `hyperdx_get_dashboard` | List all dashboards or get full detail for a specific dashboard |
| `hyperdx_save_dashboard` | Create or update a dashboard with tiles (charts, tables, numbers, search, markdown) |
| `hyperdx_delete_dashboard` | Permanently delete a dashboard and its attached alerts |
| `hyperdx_query_tile` | Execute the query for a specific dashboard tile to validate results |

View file

@ -187,6 +187,10 @@ dev-unit:
ci-unit:
npx nx run-many -t ci:unit
.PHONY: ci-triage
ci-triage:
node --test .github/scripts/__tests__/pr-triage-classify.test.js
# ---------------------------------------------------------------------------
# E2E tests — port isolation is handled by scripts/test-e2e.sh
# ---------------------------------------------------------------------------

View file

@ -93,6 +93,34 @@ The project uses Mantine UI with **custom variants** defined in `packages/app/sr
This pattern cannot be enforced by ESLint and requires manual code review.
### EmptyState Component (REQUIRED)
**Use `EmptyState` (`@/components/EmptyState`) for all empty/no-data states.** Do not create ad-hoc inline empty states.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `icon` | `ReactNode` | — | Icon in the theme circle (hidden if not provided) |
| `title` | `string` | — | Heading text (headline style — no trailing period) |
| `description` | `ReactNode` | — | Subtext below the title |
| `children` | `ReactNode` | — | Actions (buttons, links) below description |
| `variant` | `"default" \| "card"` | `"default"` | `"card"` wraps in a bordered Paper |
```tsx
// ❌ BAD - ad-hoc inline empty states
<div className="text-center my-4 fs-8">No data</div>
<Text ta="center" c="dimmed">Nothing here</Text>
// ✅ GOOD - use the EmptyState component
<EmptyState
icon={<IconBell size={32} />}
title="No alerts created yet"
description="Create alerts from dashboard charts or saved searches."
variant="card"
/>
```
**Title copy**: Treat `title` as a short headline (like `Title` in the UI). Do **not** end it with a period. Use `description` for full sentences, which should use normal punctuation including a trailing period when appropriate. Match listing pages (e.g. dashboards and saved searches use parallel phrasing such as “No matching … yet” / “No … yet” without dots).
## Refactoring
- Edit files directly - don't create `component-v2.tsx` copies

View file

@ -5,6 +5,9 @@ services:
context: .
dockerfile: docker/otel-collector/Dockerfile
target: dev
args:
OTEL_COLLECTOR_VERSION: ${OTEL_COLLECTOR_VERSION}
OTEL_COLLECTOR_CORE_VERSION: ${OTEL_COLLECTOR_CORE_VERSION}
environment:
CLICKHOUSE_ENDPOINT: 'tcp://ch-server:9000?dial_timeout=10s'
HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE: ${HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE}

View file

@ -26,6 +26,9 @@ services:
context: .
dockerfile: docker/otel-collector/Dockerfile
target: dev
args:
OTEL_COLLECTOR_VERSION: ${OTEL_COLLECTOR_VERSION}
OTEL_COLLECTOR_CORE_VERSION: ${OTEL_COLLECTOR_CORE_VERSION}
environment:
CLICKHOUSE_ENDPOINT: 'tcp://ch-server:9000?dial_timeout=10s'
CLICKHOUSE_PROMETHEUS_METRICS_ENDPOINT: 'ch-server:9363'
@ -63,6 +66,9 @@ services:
context: .
dockerfile: docker/otel-collector/Dockerfile
target: dev
args:
OTEL_COLLECTOR_VERSION: ${OTEL_COLLECTOR_VERSION}
OTEL_COLLECTOR_CORE_VERSION: ${OTEL_COLLECTOR_CORE_VERSION}
environment:
CLICKHOUSE_ENDPOINT: 'tcp://ch-server:9000?dial_timeout=10s'
CLICKHOUSE_PROMETHEUS_METRICS_ENDPOINT: 'ch-server:9363'
@ -74,9 +80,9 @@ services:
CUSTOM_OTELCOL_CONFIG_FILE: '/etc/otelcol-contrib/custom.config.yaml'
# Uncomment to enable stdout logging for the OTel collector
OTEL_SUPERVISOR_LOGS: 'true'
# Uncomment to enable JSON schema in ClickHouse
# Enable JSON schema in the ClickHouse exporter (per-exporter config)
# Be sure to also set BETA_CH_OTEL_JSON_SCHEMA_ENABLED to 'true' in ch-server
OTEL_AGENT_FEATURE_GATE_ARG: '--feature-gates=clickhouse.json'
HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE: 'true'
volumes:
- ./docker/otel-collector/config.yaml:/etc/otelcol-contrib/config.yaml
- ./docker/otel-collector/supervisor_docker.yaml.tmpl:/etc/otel/supervisor.yaml.tmpl
@ -103,7 +109,7 @@ services:
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE: ${HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE}
# Set to 'true' to allow for proper OTel JSON Schema creation
# Be sure to also set the OTEL_AGENT_FEATURE_GATE_ARG env in otel-collector
# Be sure to also set HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE in otel-collector
# BETA_CH_OTEL_JSON_SCHEMA_ENABLED: 'true'
volumes:
- ./docker/clickhouse/local/config.xml:/etc/clickhouse-server/config.xml

View file

@ -9,15 +9,31 @@
ARG NODE_VERSION=22.22
ARG CLICKHOUSE_VERSION=26.1
ARG OTEL_COLLECTOR_VERSION=0.147.0
ARG OTEL_COLLECTOR_OPAMPSUPERVISOR_VERSION=0.147.0
ARG OTEL_COLLECTOR_VERSION=0.149.0
ARG OTEL_COLLECTOR_CORE_VERSION=1.55.0
# base #############################################################################################
# == Otel Collector Image ==
FROM otel/opentelemetry-collector-contrib:${OTEL_COLLECTOR_VERSION} AS otel_collector_base
FROM otel/opentelemetry-collector-opampsupervisor:${OTEL_COLLECTOR_OPAMPSUPERVISOR_VERSION} AS otel_collector_opampsupervisor_base
FROM otel/opentelemetry-collector-opampsupervisor:${OTEL_COLLECTOR_VERSION} AS otel_collector_opampsupervisor_base
FROM kukymbr/goose-docker@sha256:0cd025636df126e7f66472861ca4db3683bc649be46cd1f6ef1a316209058e23 AS goose
# Build the custom HyperDX collector binary using OCB (OpenTelemetry Collector Builder).
# This replaces the pre-built otel/opentelemetry-collector-contrib image so we can
# include custom receiver/processor components alongside the standard contrib ones.
# Note: The official OCB image may ship an older Go than the contrib modules require,
# so we copy the ocb binary into a golang base with a newer toolchain.
FROM otel/opentelemetry-collector-builder:${OTEL_COLLECTOR_VERSION} AS ocb-bin
FROM golang:1.26-alpine AS ocb-builder
ARG OTEL_COLLECTOR_VERSION
ARG OTEL_COLLECTOR_CORE_VERSION
COPY --from=ocb-bin /usr/local/bin/ocb /usr/local/bin/ocb
WORKDIR /build
COPY packages/otel-collector/builder-config.yaml .
RUN --mount=type=cache,target=/go/pkg/mod,id=ocb-gomod \
--mount=type=cache,target=/root/.cache/go-build,id=ocb-gobuild \
sed -i "s/__OTEL_COLLECTOR_VERSION__/${OTEL_COLLECTOR_VERSION}/g; s/__OTEL_COLLECTOR_CORE_VERSION__/${OTEL_COLLECTOR_CORE_VERSION}/g" builder-config.yaml && \
CGO_ENABLED=0 ocb --config=builder-config.yaml
# Build the Go migration tool with full TLS support for ClickHouse
FROM golang:1.26-alpine AS migrate-builder
WORKDIR /build
@ -132,7 +148,7 @@ ARG USER_GID=10001
ENV CODE_VERSION=$CODE_VERSION
ENV OTEL_RESOURCE_ATTRIBUTES="service.version=$CODE_VERSION"
# Copy from otel collector bases
COPY --from=otel_collector_base --chmod=755 /otelcol-contrib /otelcontribcol
COPY --from=ocb-builder --chmod=755 /build/output/otelcol-hyperdx /otelcontribcol
COPY --from=otel_collector_opampsupervisor_base --chmod=755 /usr/local/bin/opampsupervisor /usr/local/bin/opampsupervisor
# Copy Node.js runtime from node base

View file

@ -1,12 +1,31 @@
## base #############################################################################################
FROM otel/opentelemetry-collector-contrib:0.147.0 AS col
FROM otel/opentelemetry-collector-opampsupervisor:0.147.0 AS supervisor
ARG OTEL_COLLECTOR_VERSION=0.149.0
ARG OTEL_COLLECTOR_CORE_VERSION=1.55.0
FROM otel/opentelemetry-collector-opampsupervisor:${OTEL_COLLECTOR_VERSION} AS supervisor
FROM hairyhenderson/gomplate:v4.3.3-alpine AS gomplate
FROM kukymbr/goose-docker@sha256:0cd025636df126e7f66472861ca4db3683bc649be46cd1f6ef1a316209058e23 AS goose
# Build the Go migration tool with full TLS support for ClickHouse
# Build the custom HyperDX collector binary using OCB (OpenTelemetry Collector Builder).
# This replaces the pre-built otel/opentelemetry-collector-contrib image so we can
# include custom receiver/processor components alongside the standard contrib ones.
# Note: Build context must be repo root (use: docker build -f docker/otel-collector/Dockerfile .)
FROM golang:1.25-alpine AS migrate-builder
# Note: The official OCB image may ship an older Go than the contrib modules require,
# so we copy the ocb binary into a golang base with a newer toolchain.
FROM otel/opentelemetry-collector-builder:${OTEL_COLLECTOR_VERSION} AS ocb-bin
FROM golang:1.26-alpine AS ocb-builder
ARG OTEL_COLLECTOR_VERSION
ARG OTEL_COLLECTOR_CORE_VERSION
COPY --from=ocb-bin /usr/local/bin/ocb /usr/local/bin/ocb
WORKDIR /build
COPY packages/otel-collector/builder-config.yaml .
RUN --mount=type=cache,target=/go/pkg/mod,id=ocb-gomod \
--mount=type=cache,target=/root/.cache/go-build,id=ocb-gobuild \
sed -i "s/__OTEL_COLLECTOR_VERSION__/${OTEL_COLLECTOR_VERSION}/g; s/__OTEL_COLLECTOR_CORE_VERSION__/${OTEL_COLLECTOR_CORE_VERSION}/g" builder-config.yaml && \
CGO_ENABLED=0 ocb --config=builder-config.yaml
# Build the Go migration tool with full TLS support for ClickHouse
FROM golang:1.26-alpine AS migrate-builder
WORKDIR /build
COPY packages/otel-collector/go.mod packages/otel-collector/go.sum ./
RUN go mod download
@ -38,7 +57,7 @@ COPY --from=migrate-builder /migrate /usr/local/bin/migrate
USER ${USER_UID}:${USER_GID}
COPY --from=supervisor --chmod=755 /usr/local/bin/opampsupervisor /opampsupervisor
COPY --from=col --chmod=755 /otelcol-contrib /otelcontribcol
COPY --from=ocb-builder --chmod=755 /build/output/otelcol-hyperdx /otelcontribcol
# Copy entrypoint and log tail wrapper scripts
COPY --chmod=755 docker/otel-collector/entrypoint.sh /entrypoint.sh

View file

@ -33,6 +33,7 @@ exporters:
logs_table_name: hyperdx_sessions
timeout: 5s
create_schema: ${env:HYPERDX_OTEL_EXPORTER_CREATE_LEGACY_SCHEMA:-false}
json: ${env:HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE:-false}
retry_on_failure:
enabled: true
initial_interval: 5s
@ -46,6 +47,7 @@ exporters:
ttl: 720h
timeout: 5s
create_schema: ${env:HYPERDX_OTEL_EXPORTER_CREATE_LEGACY_SCHEMA:-false}
json: ${env:HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE:-false}
retry_on_failure:
enabled: true
initial_interval: 5s

View file

@ -1,8 +1,26 @@
#!/bin/sh
set -e
# Fall back to legacy schema when the ClickHouse JSON feature gate is enabled
# DEPRECATED: The clickhouse.json feature gate has been removed upstream.
# When OTEL_AGENT_FEATURE_GATE_ARG contains clickhouse.json, strip it and
# map it to HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE instead. Other feature gates
# are preserved and passed through to the collector.
if echo "$OTEL_AGENT_FEATURE_GATE_ARG" | grep -q "clickhouse.json"; then
echo "WARNING: '--feature-gates=clickhouse.json' is deprecated and no longer supported by the collector."
echo "WARNING: Use HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE=true instead. This flag will be removed in a future release."
export HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE=true
# Strip clickhouse.json from the feature gates, keeping any other gates
REMAINING_GATES=$(echo "$OTEL_AGENT_FEATURE_GATE_ARG" | sed 's/--feature-gates=//' | tr ',' '\n' | grep -v 'clickhouse.json' | tr '\n' ',' | sed 's/,$//')
if [ -n "$REMAINING_GATES" ]; then
export OTEL_AGENT_FEATURE_GATE_ARG="--feature-gates=$REMAINING_GATES"
else
unset OTEL_AGENT_FEATURE_GATE_ARG
fi
fi
# Fall back to legacy schema when ClickHouse JSON exporter mode is enabled
if [ "$HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE" = "true" ]; then
export HYPERDX_OTEL_EXPORTER_CREATE_LEGACY_SCHEMA=true
fi
@ -39,7 +57,7 @@ if [ -z "$OPAMP_SERVER_URL" ]; then
COLLECTOR_ARGS="$COLLECTOR_ARGS --config $CUSTOM_OTELCOL_CONFIG_FILE"
fi
# Pass feature gates to the collector in standalone mode
# Pass remaining feature gates to the collector in standalone mode
if [ -n "$OTEL_AGENT_FEATURE_GATE_ARG" ]; then
COLLECTOR_ARGS="$COLLECTOR_ARGS $OTEL_AGENT_FEATURE_GATE_ARG"
fi

View file

@ -23,8 +23,8 @@ agent:
{{- if getenv "CUSTOM_OTELCOL_CONFIG_FILE" }}
- {{ getenv "CUSTOM_OTELCOL_CONFIG_FILE" }}
{{- end }}
args:
{{- if getenv "OTEL_AGENT_FEATURE_GATE_ARG" }}
args:
- {{ getenv "OTEL_AGENT_FEATURE_GATE_ARG" }}
{{- end }}

View file

@ -27,7 +27,7 @@
"project": ["src/**/*.ts"]
}
},
"ignore": ["scripts/dev-portal/**"],
"ignore": ["scripts/dev-portal/**", ".github/scripts/**"],
"ignoreBinaries": ["make", "migrate", "playwright"],
"ignoreDependencies": [
"@dotenvx/dotenvx",

View file

@ -42,6 +42,7 @@
"run:clickhouse": "nx run @hyperdx/app:run:clickhouse",
"dev": "sh -c '. ./scripts/dev-env.sh && yarn build:common-utils && dotenvx run --convention=nextjs -- docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml up -d && yarn app:dev; dotenvx run --convention=nextjs -- docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml down'",
"dev:local": "IS_LOCAL_APP_MODE='DANGEROUSLY_is_local_app_mode💀' yarn dev",
"cli:dev": "yarn workspace @hyperdx/cli dev",
"dev:down": "sh -c '. ./scripts/dev-env.sh && docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml down && sh ./scripts/dev-kill-ports.sh'",
"dev:compose": "sh -c '. ./scripts/dev-env.sh && docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml'",
"knip": "knip",

View file

@ -12,5 +12,7 @@ MONGO_URI=mongodb://localhost:${HDX_CI_MONGO_PORT:-39999}/hyperdx-test
NODE_ENV=test
PORT=${HDX_CI_API_PORT:-19000}
OPAMP_PORT=${HDX_CI_OPAMP_PORT:-14320}
# Default to only logging warnings/errors. Adjust if you need more verbosity
HYPERDX_LOG_LEVEL=warn
# Default to only logging errors. Adjust if you need more verbosity.
# Note: the logger module is mocked in jest.setup.ts to suppress expected
# operational noise (validation errors, MCP tool errors, etc.) during tests.
HYPERDX_LOG_LEVEL=error

View file

@ -1,5 +1,15 @@
# @hyperdx/api
## 2.23.2
## 2.23.1
### Patch Changes
- f8d2edde: feat: Show created/updated metadata for saved searches and dashboards
- Updated dependencies [24767c58]
- @hyperdx/common-utils@0.17.1
## 2.23.0
### Minor Changes

View file

@ -1,8 +1,12 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
const { createJsWithTsPreset } = require('ts-jest');
const tsJestTransformCfg = createJsWithTsPreset();
/** @type {import("jest").Config} **/
module.exports = {
...tsJestTransformCfg,
setupFilesAfterEnv: ['<rootDir>/../jest.setup.ts'],
setupFiles: ['dotenv-expand/config'],
preset: 'ts-jest',
testEnvironment: 'node',
verbose: true,
rootDir: './src',

View file

@ -1,12 +1,11 @@
// @eslint-disable @typescript-eslint/no-var-requires
jest.retryTimes(1, { logErrorsBeforeRetry: true });
global.console = {
...console,
// Turn off noisy console logs in tests
debug: jest.fn(),
info: jest.fn(),
};
// Suppress noisy console output during test runs.
// - debug/info: ClickHouse query logging, server startup messages
// - warn: expected column-not-found warnings from renderChartConfig on CTE tables
jest.spyOn(console, 'debug').mockImplementation(() => {});
jest.spyOn(console, 'info').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
// Mock alert notification functions to prevent HTTP calls during tests
jest.mock('@/utils/slack', () => ({

View file

@ -70,7 +70,13 @@
"type": "string",
"enum": [
"above",
"below"
"below",
"above_exclusive",
"below_or_equal",
"equal",
"not_equal",
"between",
"not_between"
],
"description": "Threshold comparison direction."
},
@ -162,7 +168,7 @@
},
"tileId": {
"type": "string",
"description": "Tile ID for tile-based alerts. May not be a Raw-SQL-based tile.",
"description": "Tile ID for tile-based alerts. Must be a line, stacked bar, or number type tile.",
"nullable": true,
"example": "65f5e4a3b9e77c001a901234"
},
@ -180,9 +186,15 @@
},
"threshold": {
"type": "number",
"description": "Threshold value for triggering the alert.",
"description": "Threshold value for triggering the alert. For between and not_between threshold types, this is the lower bound.",
"example": 100
},
"thresholdMax": {
"type": "number",
"nullable": true,
"description": "Upper bound for between and not_between threshold types. Required when thresholdType is between or not_between, must be >= threshold.",
"example": 500
},
"interval": {
"$ref": "#/components/schemas/AlertInterval",
"description": "Evaluation interval for the alert.",

View file

@ -1,6 +1,6 @@
{
"name": "@hyperdx/api",
"version": "2.23.0",
"version": "2.23.2",
"license": "MIT",
"private": true,
"engines": {
@ -10,9 +10,10 @@
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/openai": "^3.0.47",
"@esm2cjs/p-queue": "^7.3.0",
"@hyperdx/common-utils": "^0.17.0",
"@hyperdx/common-utils": "^0.17.1",
"@hyperdx/node-opentelemetry": "^0.9.0",
"@hyperdx/passport-local-mongoose": "^9.0.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/host-metrics": "^0.35.5",
"@opentelemetry/sdk-metrics": "^1.30.1",
@ -57,7 +58,7 @@
"@types/cors": "^2.8.14",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.7",
"@types/jest": "^28.1.1",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.14.198",
"@types/minimist": "^1.2.2",
"@types/ms": "^0.7.31",
@ -65,7 +66,7 @@
"@types/supertest": "^2.0.12",
"@types/swagger-jsdoc": "^6",
"@types/uuid": "^8.3.4",
"jest": "^28.1.3",
"jest": "^30.2.0",
"migrate-mongo": "^11.0.0",
"nodemon": "^2.0.20",
"pino-pretty": "^13.1.1",
@ -82,13 +83,14 @@
"scripts": {
"start": "node ./build/index.js",
"dev": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/index.ts",
"dev:mcp": "npx @modelcontextprotocol/inspector",
"dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts",
"build": "rimraf ./build && tsc && tsc-alias && cp -r ./src/opamp/proto ./build/opamp/",
"lint": "npx eslint --quiet . --ext .ts",
"lint:fix": "npx eslint . --ext .ts --fix",
"ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:openapi",
"ci:int": "DOTENV_CONFIG_PATH=.env.test DOTENV_CONFIG_OVERRIDE=true jest --runInBand --ci --forceExit --coverage",
"dev:int": "DOTENV_CONFIG_PATH=.env.test DOTENV_CONFIG_OVERRIDE=true jest --runInBand --forceExit --coverage",
"dev:int": "DOTENV_CONFIG_PATH=.env.test DOTENV_CONFIG_OVERRIDE=true jest --runInBand --coverage",
"dev:migrate-db-create": "ts-node node_modules/.bin/migrate-mongo create -f migrate-mongo-config.ts",
"dev:migrate-db": "ts-node node_modules/.bin/migrate-mongo up -f migrate-mongo-config.ts",
"dev:migrate-ch-create": "migrate create -ext sql -dir ./migrations/ch -seq",

View file

@ -5,6 +5,7 @@ import session from 'express-session';
import onHeaders from 'on-headers';
import * as config from './config';
import mcpRouter from './mcp/app';
import { isUserAuthenticated } from './middleware/auth';
import defaultCors from './middleware/cors';
import { appErrorHandler } from './middleware/error';
@ -12,6 +13,7 @@ import routers from './routers/api';
import clickhouseProxyRouter from './routers/api/clickhouseProxy';
import connectionsRouter from './routers/api/connections';
import favoritesRouter from './routers/api/favorites';
import pinnedFiltersRouter from './routers/api/pinnedFilters';
import savedSearchRouter from './routers/api/savedSearch';
import sourcesRouter from './routers/api/sources';
import externalRoutersV2 from './routers/external-api/v2';
@ -79,7 +81,7 @@ app.use(defaultCors);
// ---------------------------------------------------------------------
// ----------------------- Background Jobs -----------------------------
// ---------------------------------------------------------------------
if (config.USAGE_STATS_ENABLED) {
if (config.USAGE_STATS_ENABLED && !config.IS_CI) {
usageStats();
}
// ---------------------------------------------------------------------
@ -90,6 +92,9 @@ if (config.USAGE_STATS_ENABLED) {
// PUBLIC ROUTES
app.use('/', routers.rootRouter);
// SELF-AUTHENTICATED ROUTES (validated via access key, not session middleware)
app.use('/mcp', mcpRouter);
// PRIVATE ROUTES
app.use('/ai', isUserAuthenticated, routers.aiRouter);
app.use('/alerts', isUserAuthenticated, routers.alertsRouter);
@ -101,6 +106,7 @@ app.use('/connections', isUserAuthenticated, connectionsRouter);
app.use('/sources', isUserAuthenticated, sourcesRouter);
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
app.use('/favorites', isUserAuthenticated, favoritesRouter);
app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter);
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
// ---------------------------------------------------------------------

View file

@ -1,33 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renderChartConfig K8s Semantic Convention Migrations with metricNameSql should handle gauge metric with metricNameSql and groupBy 1`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"arrayElement(ResourceAttributes, 'k8s.pod.name')": "test-pod",
"avg(toFloat64OrDefault(toString(LastValue)))": 45,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:01:00Z",
"arrayElement(ResourceAttributes, 'k8s.pod.name')": "test-pod",
"avg(toFloat64OrDefault(toString(LastValue)))": 50,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"arrayElement(ResourceAttributes, 'k8s.pod.name')": "test-pod",
"avg(toFloat64OrDefault(toString(LastValue)))": 55,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:03:00Z",
"arrayElement(ResourceAttributes, 'k8s.pod.name')": "test-pod",
"avg(toFloat64OrDefault(toString(LastValue)))": 60,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:04:00Z",
"arrayElement(ResourceAttributes, 'k8s.pod.name')": "test-pod",
"avg(toFloat64OrDefault(toString(LastValue)))": 65,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"arrayElement(ResourceAttributes, 'k8s.pod.name')": "test-pod",
"avg(toFloat64OrDefault(toString(LastValue)))": 70,
@ -36,16 +36,16 @@ Array [
`;
exports[`renderChartConfig K8s Semantic Convention Migrations with metricNameSql should handle metrics without metricNameSql (backward compatibility) 1`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 45,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:01:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 50,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 55,
},
@ -53,28 +53,28 @@ Array [
`;
exports[`renderChartConfig K8s Semantic Convention Migrations with metricNameSql should query k8s.pod.cpu.utilization gauge metric using metricNameSql to handle both old and new conventions 1`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 45,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:01:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 50,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 55,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:03:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 60,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:04:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 65,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 70,
},
@ -82,12 +82,12 @@ Array [
`;
exports[`renderChartConfig Query Events - Logs simple select + group by query logs 1`] = `
Array [
Object {
[
{
"ServiceName": "app",
"count": "1",
},
Object {
{
"ServiceName": "api",
"count": "1",
},
@ -95,31 +95,31 @@ Array [
`;
exports[`renderChartConfig Query Events - Logs simple select + where query logs 1`] = `
Array [
Object {
[
{
"Body": "Oh no! Something went wrong!",
},
]
`;
exports[`renderChartConfig Query Metrics - Gauge single avg gauge with group-by 1`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"arrayElement(ResourceAttributes, 'host')": "host2",
"avg(toFloat64OrDefault(toString(LastValue)))": 4,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"arrayElement(ResourceAttributes, 'host')": "host1",
"avg(toFloat64OrDefault(toString(LastValue)))": 6.25,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"arrayElement(ResourceAttributes, 'host')": "host2",
"avg(toFloat64OrDefault(toString(LastValue)))": 4,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"arrayElement(ResourceAttributes, 'host')": "host1",
"avg(toFloat64OrDefault(toString(LastValue)))": 80,
@ -128,12 +128,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Gauge single avg gauge with where 1`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 6.25,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 80,
},
@ -141,12 +141,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Gauge single max gauge with delta 1`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"max(toFloat64OrDefault(toString(LastValue)))": 5,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"max(toFloat64OrDefault(toString(LastValue)))": -1.6666666666666667,
},
@ -154,23 +154,23 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Gauge single max gauge with delta and group by 1`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"arrayElement(ResourceAttributes, 'host')": "host2",
"max(toFloat64OrDefault(toString(LastValue)))": 5,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"arrayElement(ResourceAttributes, 'host')": "host1",
"max(toFloat64OrDefault(toString(LastValue)))": -72.91666666666667,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"arrayElement(ResourceAttributes, 'host')": "host2",
"max(toFloat64OrDefault(toString(LastValue)))": -1.6666666666666667,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"arrayElement(ResourceAttributes, 'host')": "host1",
"max(toFloat64OrDefault(toString(LastValue)))": -33.333333333333336,
@ -179,12 +179,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Gauge single max/avg/sum gauge 1`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 5.125,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"avg(toFloat64OrDefault(toString(LastValue)))": 42,
},
@ -192,12 +192,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Gauge single max/avg/sum gauge 2`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"max(toFloat64OrDefault(toString(LastValue)))": 6.25,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"max(toFloat64OrDefault(toString(LastValue)))": 80,
},
@ -205,12 +205,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Gauge single max/avg/sum gauge 3`] = `
Array [
Object {
[
{
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"sum(toFloat64OrDefault(toString(LastValue)))": 10.25,
},
Object {
{
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
"sum(toFloat64OrDefault(toString(LastValue)))": 84,
},
@ -218,32 +218,32 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Histogram should bucket correctly when grouping by a single attribute 1`] = `
Array [
Object {
[
{
"Value": 3.5714285714285716,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"group": Array [
"group": [
"host-b",
],
},
Object {
{
"Value": 8.382352941176471,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"group": Array [
"group": [
"host-a",
],
},
Object {
{
"Value": 3.5,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"group": Array [
"group": [
"host-b",
],
},
Object {
{
"Value": 4.95,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"group": Array [
"group": [
"host-a",
],
},
@ -251,99 +251,99 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Histogram should bucket correctly when grouping by multiple attributes 1`] = `
Array [
Object {
[
{
"Value": 2.916666666666667,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"group": Array [
"group": [
"host-b",
"service-2",
],
},
Object {
{
"Value": 4.852941176470588,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"group": Array [
"group": [
"host-a",
"service-2",
],
},
Object {
{
"Value": 8.75,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"group": Array [
"group": [
"host-a",
"service-1",
],
},
Object {
{
"Value": 3.1578947368421053,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"group": Array [
"group": [
"host-b",
"service-1",
],
},
Object {
{
"Value": 58.33333333333333,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"group": Array [
"group": [
"host-a",
"service-3",
],
},
Object {
{
"Value": 6.25,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
"group": Array [
"group": [
"host-b",
"service-3",
],
},
Object {
{
"Value": 3.4090909090909087,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"group": Array [
"group": [
"host-b",
"service-1",
],
},
Object {
{
"Value": 7.916666666666667,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"group": Array [
"group": [
"host-a",
"service-1",
],
},
Object {
{
"Value": 3.25,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"group": Array [
"group": [
"host-b",
"service-3",
],
},
Object {
{
"Value": 4.25,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"group": Array [
"group": [
"host-a",
"service-3",
],
},
Object {
{
"Value": 4.75,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"group": Array [
"group": [
"host-a",
"service-2",
],
},
Object {
{
"Value": 3.888888888888889,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
"group": Array [
"group": [
"host-b",
"service-2",
],
@ -352,12 +352,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Histogram should bucket correctly when no grouping is defined 1`] = `
Array [
Object {
[
{
"Value": 5.241935483870968,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
},
Object {
{
"Value": 4.40625,
"__hdx_time_bucket": "2022-01-05T00:02:00Z",
},
@ -365,8 +365,8 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Histogram two_timestamps_bounded histogram (p25) 1`] = `
Array [
Object {
[
{
"Value": 7.5,
"__hdx_time_bucket": "2022-01-05T00:01:00Z",
},
@ -374,8 +374,8 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Histogram two_timestamps_bounded histogram (p50) 1`] = `
Array [
Object {
[
{
"Value": 20,
"__hdx_time_bucket": "2022-01-05T00:01:00Z",
},
@ -383,8 +383,8 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Histogram two_timestamps_bounded histogram (p90) 1`] = `
Array [
Object {
[
{
"Value": 30,
"__hdx_time_bucket": "2022-01-05T00:01:00Z",
},
@ -392,8 +392,8 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Histogram two_timestamps_lower_bound_inf histogram (p50) 1`] = `
Array [
Object {
[
{
"Value": 0.5,
"__hdx_time_bucket": "2022-01-05T00:01:00Z",
},
@ -401,12 +401,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Sum calculates min_rate/max_rate correctly for sum metrics: maxSum 1`] = `
Array [
Object {
[
{
"Value": 24,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
},
Object {
{
"Value": 134,
"__hdx_time_bucket": "2022-01-05T00:10:00Z",
},
@ -414,12 +414,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Sum calculates min_rate/max_rate correctly for sum metrics: minSum 1`] = `
Array [
Object {
[
{
"Value": 15,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
},
Object {
{
"Value": 52,
"__hdx_time_bucket": "2022-01-05T00:10:00Z",
},
@ -427,12 +427,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Sum handles counter resets correctly for sum metrics 1`] = `
Array [
Object {
[
{
"Value": 15,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
},
Object {
{
"Value": 52,
"__hdx_time_bucket": "2022-01-05T00:10:00Z",
},
@ -440,20 +440,20 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Sum single sum rate 1`] = `
Array [
Object {
[
{
"Value": 19,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
},
Object {
{
"Value": 79,
"__hdx_time_bucket": "2022-01-05T00:05:00Z",
},
Object {
{
"Value": 5813,
"__hdx_time_bucket": "2022-01-05T00:10:00Z",
},
Object {
{
"Value": 78754,
"__hdx_time_bucket": "2022-01-05T00:15:00Z",
},
@ -461,12 +461,12 @@ Array [
`;
exports[`renderChartConfig Query Metrics - Sum sum values as without rate computation 1`] = `
Array [
Object {
[
{
"Value": 950400,
"__hdx_time_bucket": "2022-01-05T00:00:00Z",
},
Object {
{
"Value": 1641600,
"__hdx_time_bucket": "2022-01-05T00:10:00Z",
},
@ -474,19 +474,19 @@ Array [
`;
exports[`renderChartConfig Query settings handles the the query settings 1`] = `
Array [
Object {
[
{
"Body": "Oh no! Something went wrong!",
},
Object {
{
"Body": "This is a test message.",
},
]
`;
exports[`renderChartConfig aggFn numeric agg functions should handle numeric values as strings 1`] = `
Array [
Object {
[
{
"AVG(toFloat64OrDefault(toString(strVal)))": 0.5,
"MAX(toFloat64OrDefault(toString(strVal)))": 3,
"MIN(toFloat64OrDefault(toString(strVal)))": -1.1,
@ -497,8 +497,8 @@ Array [
`;
exports[`renderChartConfig aggFn numeric agg functions should use default values for other types 1`] = `
Array [
Object {
[
{
"AVG(toFloat64OrDefault(toString(strVal)))": 0,
"MAX(toFloat64OrDefault(toString(strVal)))": 0,
"MIN(toFloat64OrDefault(toString(strVal)))": 0,

View file

@ -1,3 +1,7 @@
import {
displayTypeSupportsRawSqlAlerts,
validateRawSqlForAlert,
} from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import { sign, verify } from 'jsonwebtoken';
import { groupBy } from 'lodash';
@ -5,13 +9,7 @@ import ms from 'ms';
import { z } from 'zod';
import type { ObjectId } from '@/models';
import Alert, {
AlertChannel,
AlertInterval,
AlertSource,
AlertThresholdType,
IAlert,
} from '@/models/alert';
import Alert, { AlertSource, IAlert } from '@/models/alert';
import Dashboard, { IDashboard } from '@/models/dashboard';
import { ISavedSearch, SavedSearch } from '@/models/savedSearch';
import { IUser } from '@/models/user';
@ -20,34 +18,23 @@ import { Api400Error } from '@/utils/errors';
import logger from '@/utils/logger';
import { alertSchema, objectIdSchema } from '@/utils/zod';
export type AlertInput = {
export type AlertInput = Omit<
IAlert,
| 'id'
| 'scheduleStartAt'
| 'savedSearchId'
| 'createdAt'
| 'createdBy'
| 'updatedAt'
| 'team'
| 'state'
> & {
id?: string;
source?: AlertSource;
channel: AlertChannel;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
// Replace the Date-type fields from IAlert
scheduleStartAt?: string | null;
thresholdType: AlertThresholdType;
threshold: number;
// Message template
name?: string | null;
message?: string | null;
// Log alerts
groupBy?: string;
// Replace the ObjectId-type fields from IAlert
savedSearchId?: string;
// Chart alerts
dashboardId?: string;
tileId?: string;
// Silenced
silenced?: {
by?: ObjectId;
at: Date;
until: Date;
};
};
const validateObjectId = (id: string | undefined, message: string) => {
@ -82,7 +69,18 @@ export const validateAlertInput = async (
}
if (tile.config != null && isRawSqlSavedChartConfig(tile.config)) {
throw new Api400Error('Cannot create an alert on a raw SQL tile');
if (!displayTypeSupportsRawSqlAlerts(tile.config.displayType)) {
throw new Api400Error(
'Alerts on Raw SQL tiles are only supported for Line, Stacked Bar, or Number display types',
);
}
const { errors } = validateRawSqlForAlert(tile.config);
if (errors.length > 0) {
throw new Api400Error(
`Raw SQL alert query is invalid: ${errors.join(', ')}`,
);
}
}
}
@ -140,6 +138,7 @@ const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
}),
source: alert.source,
threshold: alert.threshold,
thresholdMax: alert.thresholdMax,
thresholdType: alert.thresholdType,
...(userId && { createdBy: userId }),
@ -287,6 +286,20 @@ export const getAlertsEnhanced = async (teamId: ObjectId) => {
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
};
export const getAlertEnhanced = async (
alertId: ObjectId | string,
teamId: ObjectId,
) => {
return Alert.findOne({ _id: alertId, team: teamId }).populate<{
savedSearch: ISavedSearch;
dashboard: IDashboard;
createdBy?: IUser;
silenced?: IAlert['silenced'] & {
by: IUser;
};
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
};
export const deleteAlert = async (id: string, teamId: ObjectId) => {
return Alert.deleteOne({
_id: id,

View file

@ -1,7 +1,6 @@
import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
BuilderSavedChartConfig,
DashboardWithoutIdSchema,
SavedChartConfig,
Tile,
} from '@hyperdx/common-utils/dist/types';
import { map, partition, uniq } from 'lodash';
@ -19,7 +18,7 @@ import Dashboard from '@/models/dashboard';
function pickAlertsByTile(tiles: Tile[]) {
return tiles.reduce((acc, tile) => {
if (isBuilderSavedChartConfig(tile.config) && tile.config.alert) {
if (tile.config.alert) {
acc[tile.id] = tile.config.alert;
}
return acc;
@ -27,9 +26,7 @@ function pickAlertsByTile(tiles: Tile[]) {
}
type TileForAlertSync = Pick<Tile, 'id'> & {
config?:
| Pick<BuilderSavedChartConfig, 'alert'>
| { alert?: IAlert | AlertDocument };
config?: Pick<SavedChartConfig, 'alert'> | { alert?: IAlert | AlertDocument };
};
function extractTileAlertData(tiles: TileForAlertSync[]): {
@ -55,9 +52,7 @@ async function syncDashboardAlerts(
const newTilesForAlertSync: TileForAlertSync[] = newTiles.map(t => ({
id: t.id,
config: isBuilderSavedChartConfig(t.config)
? { alert: t.config.alert }
: {},
config: { alert: t.config.alert },
}));
const { tileIds: newTileIds, tileIdsWithAlerts: newTileIdsWithAlerts } =
extractTileAlertData(newTilesForAlertSync);
@ -95,7 +90,9 @@ async function syncDashboardAlerts(
export async function getDashboards(teamId: ObjectId) {
const [_dashboards, alerts] = await Promise.all([
Dashboard.find({ team: teamId }),
Dashboard.find({ team: teamId })
.populate('createdBy', 'email name')
.populate('updatedBy', 'email name'),
getTeamDashboardAlertsByDashboardAndTile(teamId),
]);
@ -117,12 +114,14 @@ export async function getDashboards(teamId: ObjectId) {
export async function getDashboard(dashboardId: string, teamId: ObjectId) {
const [_dashboard, alerts] = await Promise.all([
Dashboard.findOne({ _id: dashboardId, team: teamId }),
Dashboard.findOne({ _id: dashboardId, team: teamId })
.populate('createdBy', 'email name')
.populate('updatedBy', 'email name'),
getDashboardAlertsByTile(teamId, dashboardId),
]);
return {
..._dashboard,
..._dashboard?.toJSON(),
tiles: _dashboard?.tiles.map(t => ({
...t,
config: { ...t.config, alert: alerts[t.id]?.[0] },
@ -138,6 +137,8 @@ export async function createDashboard(
const newDashboard = await new Dashboard({
...dashboard,
team: teamId,
createdBy: userId,
updatedBy: userId,
}).save();
await createOrUpdateDashboardAlerts(
@ -180,6 +181,7 @@ export async function updateDashboard(
{
...updates,
tags: updates.tags && uniq(updates.tags),
updatedBy: userId,
},
{ new: true },
);

View file

@ -0,0 +1,42 @@
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
import mongoose from 'mongoose';
import type { ObjectId } from '@/models';
import PinnedFilterModel from '@/models/pinnedFilter';
/**
* Get team-level pinned filters for a team+source combination.
*/
export async function getPinnedFilters(
teamId: string | ObjectId,
sourceId: string | ObjectId,
) {
return PinnedFilterModel.findOne({
team: new mongoose.Types.ObjectId(teamId),
source: new mongoose.Types.ObjectId(sourceId),
});
}
/**
* Upsert team-level pinned filters for a team+source.
*/
export async function updatePinnedFilters(
teamId: string | ObjectId,
sourceId: string | ObjectId,
data: { fields: string[]; filters: PinnedFiltersValue },
) {
const filter = {
team: new mongoose.Types.ObjectId(teamId),
source: new mongoose.Types.ObjectId(sourceId),
};
return PinnedFilterModel.findOneAndUpdate(
filter,
{
...filter,
fields: data.fields,
filters: data.filters,
},
{ upsert: true, new: true },
);
}

View file

@ -1,16 +1,22 @@
import { SavedSearchSchema } from '@hyperdx/common-utils/dist/types';
import { groupBy, pick } from 'lodash';
import {
SavedSearchListApiResponse,
SavedSearchSchema,
} from '@hyperdx/common-utils/dist/types';
import { groupBy } from 'lodash';
import { z } from 'zod';
import { deleteSavedSearchAlerts } from '@/controllers/alerts';
import Alert from '@/models/alert';
import { SavedSearch } from '@/models/savedSearch';
import type { IUser } from '@/models/user';
type SavedSearchWithoutId = Omit<z.infer<typeof SavedSearchSchema>, 'id'>;
export async function getSavedSearches(teamId: string) {
const savedSearches = await SavedSearch.find({ team: teamId });
export async function getSavedSearches(
teamId: string,
): Promise<SavedSearchListApiResponse[]> {
const savedSearches = await SavedSearch.find({ team: teamId })
.populate('createdBy', 'email name')
.populate('updatedBy', 'email name');
const alerts = await Alert.find(
{ team: teamId, savedSearch: { $exists: true, $ne: null } },
{ __v: 0 },
@ -27,26 +33,36 @@ export async function getSavedSearches(teamId: string) {
}
export function getSavedSearch(teamId: string, savedSearchId: string) {
return SavedSearch.findOne({ _id: savedSearchId, team: teamId });
return SavedSearch.findOne({ _id: savedSearchId, team: teamId })
.populate('createdBy', 'email name')
.populate('updatedBy', 'email name');
}
export function createSavedSearch(
teamId: string,
savedSearch: SavedSearchWithoutId,
userId?: string,
) {
return SavedSearch.create({ ...savedSearch, team: teamId });
return SavedSearch.create({
...savedSearch,
team: teamId,
createdBy: userId,
updatedBy: userId,
});
}
export function updateSavedSearch(
teamId: string,
savedSearchId: string,
savedSearch: SavedSearchWithoutId,
userId?: string,
) {
return SavedSearch.findOneAndUpdate(
{ _id: savedSearchId, team: teamId },
{
...savedSearch,
team: teamId,
updatedBy: userId,
},
{ new: true },
);

View file

@ -1,5 +1,6 @@
import { createNativeClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import {
AlertThresholdType,
BuilderSavedChartConfig,
DisplayType,
RawSqlSavedChartConfig,
@ -15,7 +16,7 @@ import { AlertInput } from '@/controllers/alerts';
import { getTeam } from '@/controllers/team';
import { findUserByEmail } from '@/controllers/user';
import { mongooseConnection } from '@/models';
import { AlertInterval, AlertSource, AlertThresholdType } from '@/models/alert';
import { AlertInterval, AlertSource } from '@/models/alert';
import Server from '@/server';
import logger from '@/utils/logger';
import { MetricModel } from '@/utils/logParser';
@ -63,6 +64,13 @@ export const getTestFixtureClickHouseClient = async () => {
return clickhouseClient;
};
export const closeTestFixtureClickHouseClient = async () => {
if (clickhouseClient) {
await clickhouseClient.close();
clickhouseClient = null;
}
};
const healthCheck = async () => {
const client = await getTestFixtureClickHouseClient();
const result = await client.ping();
@ -132,6 +140,7 @@ export const closeDB = async () => {
throw new Error('ONLY execute this in CI env 😈 !!!');
}
await mongooseConnection.dropDatabase();
await mongoose.disconnect();
};
export const clearDBCollections = async () => {
@ -175,8 +184,8 @@ class MockServer extends Server {
}
}
stop() {
return new Promise<void>((resolve, reject) => {
async stop() {
await new Promise<void>((resolve, reject) => {
this.appServer.close(err => {
if (err) {
reject(err);
@ -187,13 +196,12 @@ class MockServer extends Server {
reject(err);
return;
}
super
.shutdown()
.then(() => resolve())
.catch(err => reject(err));
resolve();
});
});
});
await closeTestFixtureClickHouseClient();
await super.shutdown();
}
clearDBs() {
@ -501,7 +509,39 @@ export const makeExternalTile = (opts?: {
},
});
export const makeRawSqlTile = (opts?: { id?: string }): Tile => ({
export const makeRawSqlTile = (opts?: {
id?: string;
displayType?: DisplayType;
sqlTemplate?: string;
connectionId?: string;
}): Tile => ({
id: opts?.id ?? randomMongoId(),
x: 1,
y: 1,
w: 1,
h: 1,
config: {
configType: 'sql',
displayType: opts?.displayType ?? DisplayType.Line,
sqlTemplate: opts?.sqlTemplate ?? 'SELECT 1',
connection: opts?.connectionId ?? 'test-connection',
} satisfies RawSqlSavedChartConfig,
});
export const RAW_SQL_ALERT_TEMPLATE = [
'SELECT toStartOfInterval(Timestamp, INTERVAL {intervalSeconds:Int64} second) AS ts,',
' count() AS cnt',
' FROM default.otel_logs',
' WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
' AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
' GROUP BY ts ORDER BY ts',
].join('');
export const makeRawSqlAlertTile = (opts?: {
id?: string;
connectionId?: string;
sqlTemplate?: string;
}): Tile => ({
id: opts?.id ?? randomMongoId(),
x: 1,
y: 1,
@ -510,8 +550,33 @@ export const makeRawSqlTile = (opts?: { id?: string }): Tile => ({
config: {
configType: 'sql',
displayType: DisplayType.Line,
sqlTemplate: 'SELECT 1',
connection: 'test-connection',
sqlTemplate: opts?.sqlTemplate ?? RAW_SQL_ALERT_TEMPLATE,
connection: opts?.connectionId ?? 'test-connection',
} satisfies RawSqlSavedChartConfig,
});
export const RAW_SQL_NUMBER_ALERT_TEMPLATE = [
'SELECT count() AS cnt',
' FROM default.otel_logs',
' WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
' AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
].join('');
export const makeRawSqlNumberAlertTile = (opts?: {
id?: string;
connectionId?: string;
sqlTemplate?: string;
}): Tile => ({
id: opts?.id ?? randomMongoId(),
x: 1,
y: 1,
w: 1,
h: 1,
config: {
configType: 'sql',
displayType: DisplayType.Number,
sqlTemplate: opts?.sqlTemplate ?? RAW_SQL_NUMBER_ALERT_TEMPLATE,
connection: opts?.connectionId ?? 'test-connection',
} satisfies RawSqlSavedChartConfig,
});

View file

@ -0,0 +1,545 @@
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import * as config from '@/config';
import {
DEFAULT_DATABASE,
DEFAULT_TRACES_TABLE,
getLoggedInAgent,
getServer,
} from '@/fixtures';
import Connection from '@/models/connection';
import Dashboard from '@/models/dashboard';
import { Source } from '@/models/source';
import { McpContext } from '../tools/types';
import { callTool, createTestClient, getFirstText } from './mcpTestUtils';
describe('MCP Dashboard Tools', () => {
const server = getServer();
let team: any;
let user: any;
let traceSource: any;
let connection: any;
let client: Client;
beforeAll(async () => {
await server.start();
});
beforeEach(async () => {
const result = await getLoggedInAgent(server);
team = result.team;
user = result.user;
connection = await Connection.create({
team: team._id,
name: 'Default',
host: config.CLICKHOUSE_HOST,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
traceSource = await Source.create({
kind: SourceKind.Trace,
team: team._id,
from: {
databaseName: DEFAULT_DATABASE,
tableName: DEFAULT_TRACES_TABLE,
},
timestampValueExpression: 'Timestamp',
connection: connection._id,
name: 'Traces',
});
const context: McpContext = {
teamId: team._id.toString(),
userId: user._id.toString(),
};
client = await createTestClient(context);
});
afterEach(async () => {
await client.close();
await server.clearDBs();
});
afterAll(async () => {
await server.stop();
});
describe('hyperdx_list_sources', () => {
it('should list available sources and connections', async () => {
const result = await callTool(client, 'hyperdx_list_sources');
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
const output = JSON.parse(getFirstText(result));
expect(output.sources).toHaveLength(1);
expect(output.sources[0]).toMatchObject({
id: traceSource._id.toString(),
name: 'Traces',
kind: SourceKind.Trace,
});
expect(output.connections).toHaveLength(1);
expect(output.connections[0]).toMatchObject({
id: connection._id.toString(),
name: 'Default',
});
expect(output.usage).toBeDefined();
});
it('should include column schema for sources', async () => {
const result = await callTool(client, 'hyperdx_list_sources');
const output = JSON.parse(getFirstText(result));
const source = output.sources[0];
expect(source.columns).toBeDefined();
expect(Array.isArray(source.columns)).toBe(true);
expect(source.columns.length).toBeGreaterThan(0);
// Each column should have name, type, and jsType
expect(source.columns[0]).toHaveProperty('name');
expect(source.columns[0]).toHaveProperty('type');
expect(source.columns[0]).toHaveProperty('jsType');
});
it('should return empty sources for a team with no sources', async () => {
// Clear everything and re-register with new team
await client.close();
await server.clearDBs();
const result2 = await getLoggedInAgent(server);
const context2: McpContext = {
teamId: result2.team._id.toString(),
};
const client2 = await createTestClient(context2);
const result = await callTool(client2, 'hyperdx_list_sources');
const output = JSON.parse(getFirstText(result));
expect(output.sources).toHaveLength(0);
expect(output.connections).toHaveLength(0);
await client2.close();
});
});
describe('hyperdx_get_dashboard', () => {
it('should list all dashboards when no id provided', async () => {
await new Dashboard({
name: 'Dashboard 1',
tiles: [],
team: team._id,
tags: ['tag1'],
}).save();
await new Dashboard({
name: 'Dashboard 2',
tiles: [],
team: team._id,
tags: ['tag2'],
}).save();
const result = await callTool(client, 'hyperdx_get_dashboard', {});
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output).toHaveLength(2);
expect(output[0]).toHaveProperty('id');
expect(output[0]).toHaveProperty('name');
expect(output[0]).toHaveProperty('tags');
});
it('should get dashboard detail when id is provided', async () => {
const dashboard = await new Dashboard({
name: 'My Dashboard',
tiles: [],
team: team._id,
tags: ['test'],
}).save();
const result = await callTool(client, 'hyperdx_get_dashboard', {
id: dashboard._id.toString(),
});
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.id).toBe(dashboard._id.toString());
expect(output.name).toBe('My Dashboard');
expect(output.tags).toEqual(['test']);
expect(output.tiles).toEqual([]);
});
it('should return error for non-existent dashboard id', async () => {
const fakeId = '000000000000000000000000';
const result = await callTool(client, 'hyperdx_get_dashboard', {
id: fakeId,
});
expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('not found');
});
});
describe('hyperdx_save_dashboard', () => {
it('should create a new dashboard with tiles', async () => {
const sourceId = traceSource._id.toString();
const result = await callTool(client, 'hyperdx_save_dashboard', {
name: 'New MCP Dashboard',
tiles: [
{
name: 'Line Chart',
x: 0,
y: 0,
w: 12,
h: 4,
config: {
displayType: 'line',
sourceId,
select: [{ aggFn: 'count', where: '' }],
},
},
],
tags: ['mcp-test'],
});
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.id).toBeDefined();
expect(output.name).toBe('New MCP Dashboard');
expect(output.tiles).toHaveLength(1);
expect(output.tiles[0].config.displayType).toBe('line');
expect(output.tags).toEqual(['mcp-test']);
// Verify in database
const dashboard = await Dashboard.findById(output.id);
expect(dashboard).not.toBeNull();
expect(dashboard?.name).toBe('New MCP Dashboard');
});
it('should create a dashboard with a markdown tile', async () => {
const result = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Markdown Dashboard',
tiles: [
{
name: 'Notes',
config: {
displayType: 'markdown',
markdown: '# Hello World',
},
},
],
});
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.tiles).toHaveLength(1);
expect(output.tiles[0].config.displayType).toBe('markdown');
});
it('should update an existing dashboard', async () => {
const sourceId = traceSource._id.toString();
// Create first
const createResult = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Original Name',
tiles: [
{
name: 'Tile 1',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
});
const created = JSON.parse(getFirstText(createResult));
// Update
const updateResult = await callTool(client, 'hyperdx_save_dashboard', {
id: created.id,
name: 'Updated Name',
tiles: [
{
name: 'Updated Tile',
config: {
displayType: 'table',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
tags: ['updated'],
});
expect(updateResult.isError).toBeFalsy();
const updated = JSON.parse(getFirstText(updateResult));
expect(updated.id).toBe(created.id);
expect(updated.name).toBe('Updated Name');
expect(updated.tiles).toHaveLength(1);
expect(updated.tiles[0].name).toBe('Updated Tile');
expect(updated.tiles[0].config.displayType).toBe('table');
});
it('should return error for missing source ID', async () => {
const fakeSourceId = '000000000000000000000000';
const result = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Bad Dashboard',
tiles: [
{
name: 'Bad Tile',
config: {
displayType: 'line',
sourceId: fakeSourceId,
select: [{ aggFn: 'count' }],
},
},
],
});
expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('source');
});
it('should return error when updating non-existent dashboard', async () => {
const sourceId = traceSource._id.toString();
const result = await callTool(client, 'hyperdx_save_dashboard', {
id: '000000000000000000000000',
name: 'Ghost Dashboard',
tiles: [
{
name: 'Tile',
config: {
displayType: 'line',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
});
expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('not found');
});
it('should create a dashboard with multiple tile types', async () => {
const sourceId = traceSource._id.toString();
const result = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Multi-tile Dashboard',
tiles: [
{
name: 'Line',
x: 0,
y: 0,
w: 12,
h: 4,
config: {
displayType: 'line',
sourceId,
select: [{ aggFn: 'count' }],
},
},
{
name: 'Table',
x: 0,
y: 4,
w: 12,
h: 4,
config: {
displayType: 'table',
sourceId,
select: [{ aggFn: 'count' }],
},
},
{
name: 'Number',
x: 0,
y: 8,
w: 6,
h: 3,
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
},
},
{
name: 'Pie',
x: 6,
y: 8,
w: 6,
h: 3,
config: {
displayType: 'pie',
sourceId,
select: [{ aggFn: 'count' }],
groupBy: 'SpanName',
},
},
{
name: 'Notes',
x: 0,
y: 11,
w: 12,
h: 2,
config: { displayType: 'markdown', markdown: '# Dashboard Notes' },
},
],
});
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.tiles).toHaveLength(5);
});
it('should create a dashboard with a raw SQL tile', async () => {
const connectionId = connection._id.toString();
const result = await callTool(client, 'hyperdx_save_dashboard', {
name: 'SQL Dashboard',
tiles: [
{
name: 'Raw SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate: 'SELECT 1 AS value LIMIT 1',
},
},
],
});
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.tiles).toHaveLength(1);
});
});
describe('hyperdx_delete_dashboard', () => {
it('should delete an existing dashboard', async () => {
const dashboard = await new Dashboard({
name: 'To Delete',
tiles: [],
team: team._id,
}).save();
const result = await callTool(client, 'hyperdx_delete_dashboard', {
id: dashboard._id.toString(),
});
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.deleted).toBe(true);
expect(output.id).toBe(dashboard._id.toString());
// Verify deleted from database
const found = await Dashboard.findById(dashboard._id);
expect(found).toBeNull();
});
it('should return error for non-existent dashboard', async () => {
const result = await callTool(client, 'hyperdx_delete_dashboard', {
id: '000000000000000000000000',
});
expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('not found');
});
});
describe('hyperdx_query_tile', () => {
it('should return error for non-existent dashboard', async () => {
const result = await callTool(client, 'hyperdx_query_tile', {
dashboardId: '000000000000000000000000',
tileId: 'some-tile-id',
});
expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('not found');
});
it('should return error for non-existent tile', async () => {
const sourceId = traceSource._id.toString();
const createResult = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Tile Query Test',
tiles: [
{
name: 'My Tile',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
});
const dashboard = JSON.parse(getFirstText(createResult));
const result = await callTool(client, 'hyperdx_query_tile', {
dashboardId: dashboard.id,
tileId: 'non-existent-tile-id',
});
expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('Tile not found');
});
it('should return error for invalid time range', async () => {
const sourceId = traceSource._id.toString();
const createResult = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Time Range Test',
tiles: [
{
name: 'Tile',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
});
const dashboard = JSON.parse(getFirstText(createResult));
const result = await callTool(client, 'hyperdx_query_tile', {
dashboardId: dashboard.id,
tileId: dashboard.tiles[0].id,
startTime: 'not-a-date',
});
expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('Invalid');
});
it('should execute query for a valid tile', async () => {
const sourceId = traceSource._id.toString();
const createResult = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Query Tile Test',
tiles: [
{
name: 'Count Tile',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
});
const dashboard = JSON.parse(getFirstText(createResult));
const result = await callTool(client, 'hyperdx_query_tile', {
dashboardId: dashboard.id,
tileId: dashboard.tiles[0].id,
startTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
// Should succeed (may have empty results since no data inserted)
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,53 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import {
type CallToolResult,
CallToolResultSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { createServer } from '../mcpServer';
import { McpContext } from '../tools/types';
/**
* Connect an MCP server to an in-process Client via InMemoryTransport and
* return the client. This is the officially supported way to test MCP servers
* without accessing private SDK internals.
*/
export async function createTestClient(context: McpContext): Promise<Client> {
const mcpServer = createServer(context);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await mcpServer.connect(serverTransport);
const client = new Client({ name: 'test-client', version: '1.0.0' });
await client.connect(clientTransport);
return client;
}
/**
* Call a named MCP tool and return a properly-typed result.
*
* The SDK's `Client.callTool()` return type carries an index signature
* `[x: string]: unknown` that widens all property accesses to `unknown`.
* Re-parsing through `CallToolResultSchema` gives the concrete named type
* needed for clean test assertions.
*/
export async function callTool(
c: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<CallToolResult> {
const raw = await c.callTool({ name, arguments: args });
return CallToolResultSchema.parse(raw);
}
/**
* Extract the text from the first content item of a tool result.
* Throws if the item is not a text block.
*/
export function getFirstText(result: CallToolResult): string {
const item = result.content[0];
if (!item || item.type !== 'text') {
throw new Error(`Expected text content, got: ${JSON.stringify(item)}`);
}
return item.text;
}

View file

@ -0,0 +1,74 @@
import { parseTimeRange } from '../tools/query/helpers';
describe('parseTimeRange', () => {
it('should return default range (last 15 minutes) when no arguments provided', () => {
const before = Date.now();
const result = parseTimeRange();
const after = Date.now();
expect(result).not.toHaveProperty('error');
if ('error' in result) return;
// endDate should be approximately now
expect(result.endDate.getTime()).toBeGreaterThanOrEqual(before);
expect(result.endDate.getTime()).toBeLessThanOrEqual(after);
// startDate should be ~15 minutes before endDate
const diffMs = result.endDate.getTime() - result.startDate.getTime();
expect(diffMs).toBe(15 * 60 * 1000);
});
it('should use provided startTime and endTime', () => {
const result = parseTimeRange(
'2025-01-01T00:00:00Z',
'2025-01-02T00:00:00Z',
);
expect(result).not.toHaveProperty('error');
if ('error' in result) return;
expect(result.startDate.toISOString()).toBe('2025-01-01T00:00:00.000Z');
expect(result.endDate.toISOString()).toBe('2025-01-02T00:00:00.000Z');
});
it('should default startTime to 15 minutes before endTime', () => {
const result = parseTimeRange(undefined, '2025-06-15T10:00:00Z');
expect(result).not.toHaveProperty('error');
if ('error' in result) return;
expect(result.endDate.toISOString()).toBe('2025-06-15T10:00:00.000Z');
expect(result.startDate.toISOString()).toBe('2025-06-15T09:45:00.000Z');
});
it('should default endTime to now', () => {
const before = Date.now();
const result = parseTimeRange('2025-06-15T11:00:00Z');
const after = Date.now();
expect(result).not.toHaveProperty('error');
if ('error' in result) return;
expect(result.startDate.toISOString()).toBe('2025-06-15T11:00:00.000Z');
expect(result.endDate.getTime()).toBeGreaterThanOrEqual(before);
expect(result.endDate.getTime()).toBeLessThanOrEqual(after);
});
it('should return error for invalid startTime', () => {
const result = parseTimeRange('not-a-date', '2025-01-01T00:00:00Z');
expect(result).toHaveProperty('error');
if (!('error' in result)) return;
expect(result.error).toContain('Invalid');
});
it('should return error for invalid endTime', () => {
const result = parseTimeRange('2025-01-01T00:00:00Z', 'garbage');
expect(result).toHaveProperty('error');
if (!('error' in result)) return;
expect(result.error).toContain('Invalid');
});
it('should return error when both times are invalid', () => {
const result = parseTimeRange('bad', 'also-bad');
expect(result).toHaveProperty('error');
});
});

View file

@ -0,0 +1,233 @@
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import * as config from '@/config';
import {
DEFAULT_DATABASE,
DEFAULT_TRACES_TABLE,
getLoggedInAgent,
getServer,
} from '@/fixtures';
import Connection from '@/models/connection';
import { Source } from '@/models/source';
import { McpContext } from '../tools/types';
import { callTool, createTestClient, getFirstText } from './mcpTestUtils';
describe('MCP Query Tool', () => {
const server = getServer();
let team: any;
let user: any;
let traceSource: any;
let connection: any;
let client: Client;
beforeAll(async () => {
await server.start();
});
beforeEach(async () => {
const result = await getLoggedInAgent(server);
team = result.team;
user = result.user;
connection = await Connection.create({
team: team._id,
name: 'Default',
host: config.CLICKHOUSE_HOST,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
traceSource = await Source.create({
kind: SourceKind.Trace,
team: team._id,
from: {
databaseName: DEFAULT_DATABASE,
tableName: DEFAULT_TRACES_TABLE,
},
timestampValueExpression: 'Timestamp',
connection: connection._id,
name: 'Traces',
});
const context: McpContext = {
teamId: team._id.toString(),
userId: user._id.toString(),
};
client = await createTestClient(context);
});
afterEach(async () => {
await client.close();
await server.clearDBs();
});
afterAll(async () => {
await server.stop();
});
describe('builder queries', () => {
it('should execute a number query', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'number',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count' }],
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
const output = JSON.parse(getFirstText(result));
expect(output).toHaveProperty('result');
});
it('should execute a line chart query', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'line',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count' }],
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
it('should execute a table query', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'table',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count' }],
groupBy: 'SpanName',
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
it('should execute a pie query', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'pie',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count' }],
groupBy: 'SpanName',
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
it('should execute a stacked_bar query', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'stacked_bar',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count' }],
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
it('should use default time range when not provided', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'number',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count' }],
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
it('should return result for query with no matching data', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'number',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count', where: 'SpanName:z_impossible_value_xyz' }],
startTime: new Date(Date.now() - 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
});
describe('search queries', () => {
it('should execute a search query', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'search',
sourceId: traceSource._id.toString(),
where: '',
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
it('should respect maxResults parameter', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'search',
sourceId: traceSource._id.toString(),
maxResults: 10,
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
});
});
describe('SQL queries', () => {
it('should execute a raw SQL query', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'sql',
connectionId: connection._id.toString(),
sql: 'SELECT 1 AS value',
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
it('should execute SQL with time macros', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'sql',
connectionId: connection._id.toString(),
sql: `SELECT count() AS cnt FROM ${DEFAULT_DATABASE}.${DEFAULT_TRACES_TABLE} WHERE $__timeFilter(Timestamp) LIMIT 10`,
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});
});
describe('error handling', () => {
it('should return error for invalid time range', async () => {
const result = await callTool(client, 'hyperdx_query', {
displayType: 'number',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count' }],
startTime: 'invalid-date',
});
expect(result.isError).toBe(true);
expect(getFirstText(result)).toContain('Invalid');
});
});
});

View file

@ -0,0 +1,160 @@
// Mock OpenTelemetry and all modules that transitively import it
// These must be declared before any imports
const mockSpan = {
setAttribute: jest.fn(),
setStatus: jest.fn(),
recordException: jest.fn(),
end: jest.fn(),
};
const mockTracer = {
startActiveSpan: (
_name: string,
fn: (span: typeof mockSpan) => Promise<unknown>,
) => fn(mockSpan),
};
jest.mock('@opentelemetry/api', () => ({
__esModule: true,
default: {
trace: {
getTracer: () => mockTracer,
},
},
SpanStatusCode: {
OK: 1,
ERROR: 2,
},
}));
jest.mock('@/config', () => ({
CODE_VERSION: 'test-version',
}));
jest.mock('@/utils/logger', () => ({
__esModule: true,
default: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
import { withToolTracing } from '../utils/tracing';
describe('withToolTracing', () => {
const context = { teamId: 'team-123', userId: 'user-456' };
beforeEach(() => {
jest.clearAllMocks();
});
it('should call the handler and return its result', async () => {
const handler = jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'hello' }],
});
const traced = withToolTracing('test_tool', context, handler);
const result = await traced({ some: 'args' });
expect(handler).toHaveBeenCalledWith({ some: 'args' });
expect(result).toEqual({
content: [{ type: 'text', text: 'hello' }],
});
});
it('should set span attributes for tool name, team, and user', async () => {
const handler = jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'ok' }],
});
const traced = withToolTracing('my_tool', context, handler);
await traced({});
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
'mcp.tool.name',
'my_tool',
);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
'mcp.team.id',
'team-123',
);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
'mcp.user.id',
'user-456',
);
});
it('should not set user id attribute when userId is undefined', async () => {
const noUserContext = { teamId: 'team-123' };
const handler = jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'ok' }],
});
const traced = withToolTracing('my_tool', noUserContext, handler);
await traced({});
expect(mockSpan.setAttribute).not.toHaveBeenCalledWith(
'mcp.user.id',
expect.anything(),
);
});
it('should set OK status for successful results', async () => {
const handler = jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'ok' }],
});
const traced = withToolTracing('my_tool', context, handler);
await traced({});
expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: 1 }); // SpanStatusCode.OK
expect(mockSpan.end).toHaveBeenCalled();
});
it('should set ERROR status for isError results', async () => {
const handler = jest.fn().mockResolvedValue({
isError: true,
content: [{ type: 'text', text: 'something went wrong' }],
});
const traced = withToolTracing('my_tool', context, handler);
await traced({});
expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: 2 }); // SpanStatusCode.ERROR
expect(mockSpan.setAttribute).toHaveBeenCalledWith('mcp.tool.error', true);
expect(mockSpan.end).toHaveBeenCalled();
});
it('should set ERROR status and re-throw on handler exception', async () => {
const error = new Error('boom');
const handler = jest.fn().mockRejectedValue(error);
const traced = withToolTracing('my_tool', context, handler);
await expect(traced({})).rejects.toThrow('boom');
expect(mockSpan.setStatus).toHaveBeenCalledWith({
code: 2,
message: 'boom',
});
expect(mockSpan.recordException).toHaveBeenCalledWith(error);
expect(mockSpan.end).toHaveBeenCalled();
});
it('should record duration on the span', async () => {
const handler = jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'ok' }],
});
const traced = withToolTracing('my_tool', context, handler);
await traced({});
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
'mcp.tool.duration_ms',
expect.any(Number),
);
});
});

View file

@ -0,0 +1,58 @@
import { setTraceAttributes } from '@hyperdx/node-opentelemetry';
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { validateUserAccessKey } from '../middleware/auth';
import logger from '../utils/logger';
import rateLimiter, { rateLimiterKeyGenerator } from '../utils/rateLimiter';
import { createServer } from './mcpServer';
import { McpContext } from './tools/types';
const app = createMcpExpressApp();
const mcpRateLimiter = rateLimiter({
windowMs: 60 * 1000, // 1 minute
max: 100,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: rateLimiterKeyGenerator,
});
app.all('/', mcpRateLimiter, validateUserAccessKey, async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
});
const teamId = req.user?.team;
if (!teamId) {
logger.warn('MCP request rejected: no teamId');
res.sendStatus(403);
return;
}
const userId = req.user?._id?.toString();
const context: McpContext = {
teamId: teamId.toString(),
userId,
};
setTraceAttributes({
'mcp.team.id': context.teamId,
...(userId && { 'mcp.user.id': userId }),
});
logger.info({ teamId: context.teamId, userId }, 'MCP request received');
const server = createServer(context);
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} finally {
await server.close();
await transport.close();
}
});
export default app;

View file

@ -0,0 +1,21 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { CODE_VERSION } from '@/config';
import dashboardPrompts from './prompts/dashboards/index';
import dashboardsTools from './tools/dashboards/index';
import queryTools from './tools/query/index';
import { McpContext } from './tools/types';
export function createServer(context: McpContext) {
const server = new McpServer({
name: 'hyperdx',
version: `${CODE_VERSION}-beta`,
});
dashboardsTools(server, context);
queryTools(server, context);
dashboardPrompts(server, context);
return server;
}

View file

@ -0,0 +1,719 @@
// ─── Prompt content builders ──────────────────────────────────────────────────
// Each function returns a plain string that is injected as a prompt message.
export function buildCreateDashboardPrompt(
sourceSummary: string,
traceSourceId: string,
logSourceId: string,
description?: string,
): string {
const userContext = description
? `\nThe user wants to create a dashboard for: ${description}\nTailor the dashboard tiles to match this goal.\n`
: '';
return `You are an expert at creating HyperDX observability dashboards.
${userContext}
${sourceSummary}
IMPORTANT: Call hyperdx_list_sources first to get the full column schema and attribute keys for each source. The source IDs above are correct, but you need the schema details to write accurate queries.
== WORKFLOW ==
1. Call hyperdx_list_sources get source IDs, column schemas, and attribute keys
2. Design tiles pick tile types that match the monitoring goal
3. Call hyperdx_save_dashboard create the dashboard with all tiles
4. Call hyperdx_query_tile on each tile validate queries return data
== TILE TYPE GUIDE ==
Use BUILDER tiles (with sourceId) for most cases:
line Time-series trends (error rate, request volume, latency over time)
stacked_bar Compare categories over time (requests by service, errors by status code)
number Single KPI metric (total requests, current error rate, p99 latency)
table Ranked lists (top endpoints by latency, error counts by service)
pie Proportional breakdowns (traffic share by service, errors by type)
search Browse raw log/event rows (error logs, recent traces)
markdown Dashboard notes, section headers, or documentation
Use RAW SQL tiles (with connectionId) only for advanced queries:
Requires configType: "sql" plus a displayType (line, stacked_bar, table, number, pie)
Use when you need JOINs, sub-queries, CTEs, or queries the builder cannot express
== COLUMN NAMING ==
- Top-level columns use PascalCase by default: Duration, StatusCode, SpanName, Body, SeverityText, ServiceName
NOTE: These are defaults for the standard HyperDX schema. Custom sources may use different names.
Always call hyperdx_list_sources to get the real column names and keyColumns for each source.
- Map-type columns use bracket syntax: SpanAttributes['http.method'], ResourceAttributes['service.name']
NEVER use dot notation for Map columns (SpanAttributes.http.method) always use brackets.
- JSON-type columns use dot notation: JsonColumn.key.subkey
Check the jsType returned by hyperdx_list_sources to determine whether a column is Map or JSON.
- Call hyperdx_list_sources to discover the exact column names, types, and attribute keys
== LAYOUT GRID ==
The dashboard grid is 24 columns wide. Tiles are positioned with (x, y, w, h):
- Number tiles: w=6, h=4 fit 4 across in a row
- Line/Bar charts: w=12, h=4 fit 2 side-by-side
- Tables: w=24, h=6 full width
- Search tiles: w=24, h=6 full width
- Markdown: w=24, h=2 full width section header
Recommended layout pattern (top to bottom):
Row 0: KPI number tiles across the top (y=0)
Row 1: Time-series charts (y=4)
Row 2: Tables or search tiles (y=8)
== FILTER SYNTAX (Lucene) ==
Simple match: level:error
AND: service.name:api AND http.status_code:>=500
OR: level:error OR level:fatal
Wildcards: service.name:front*
Negation: NOT level:debug
Exists: _exists_:http.route
Range: Duration:>1000000000
Phrase: Body:"connection refused"
Grouped: (level:error OR level:fatal) AND service.name:api
== COMPLETE EXAMPLE ==
Here is a full dashboard creation call with properly structured tiles:
hyperdx_save_dashboard({
name: "Service Overview",
tags: ["overview"],
tiles: [
{
name: "Total Requests",
x: 0, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "count" }]
}
},
{
name: "Error Count",
x: 6, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR" }]
}
},
{
name: "P95 Latency (ms)",
x: 12, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.95 }]
}
},
{
name: "Request Rate by Service",
x: 0, y: 4, w: 12, h: 4,
config: {
displayType: "line",
sourceId: "${traceSourceId}",
select: [{ aggFn: "count" }],
groupBy: "ResourceAttributes['service.name']"
}
},
{
name: "Error Rate Over Time",
x: 12, y: 4, w: 12, h: 4,
config: {
displayType: "line",
sourceId: "${traceSourceId}",
select: [
{ aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", alias: "Errors" },
{ aggFn: "count", alias: "Total" }
],
asRatio: true
}
},
{
name: "Top Endpoints by Request Count",
x: 0, y: 8, w: 24, h: 6,
config: {
displayType: "table",
sourceId: "${traceSourceId}",
groupBy: "SpanName",
select: [
{ aggFn: "count", alias: "Requests" },
{ aggFn: "avg", valueExpression: "Duration", alias: "Avg Duration" },
{ aggFn: "quantile", valueExpression: "Duration", level: 0.95, alias: "P95 Duration" }
]
}
}
]
})
== STATUS CODE & SEVERITY VALUES ==
IMPORTANT: The exact values for StatusCode and SeverityText vary by deployment.
Do NOT assume values like "STATUS_CODE_ERROR", "Ok", "error", or "fatal".
Always call hyperdx_list_sources first and inspect the keyValues / mapAttributeKeys
returned for each source to discover the real values used in your data.
== COMMON MISTAKES TO AVOID ==
- Using valueExpression with aggFn "count" count does not take a valueExpression
- Forgetting valueExpression for non-count aggFns avg, sum, min, max, quantile all require it
- Using dot notation for Map-type attributes always use SpanAttributes['key'] bracket syntax for Map columns
- Not calling hyperdx_list_sources first you need real source IDs, not placeholders
- Not validating with hyperdx_query_tile after saving tiles can silently fail
- Number and Pie tiles accept exactly 1 select item not multiple
- Missing level for quantile aggFn must specify 0.5, 0.9, 0.95, or 0.99
- Assuming StatusCode or SeverityText values always inspect the source first`;
}
export function buildDashboardExamplesPrompt(
traceSourceId: string,
logSourceId: string,
connectionId: string,
pattern?: string,
): string {
const examples: Record<string, string> = {};
examples['service_overview'] = `
== SERVICE HEALTH OVERVIEW ==
A high-level view of service health with KPIs, trends, and endpoint details.
{
name: "Service Health Overview",
tags: ["overview", "service"],
tiles: [
{
name: "Total Requests",
x: 0, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "count" }]
}
},
{
name: "Error Count",
x: 6, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR" }]
}
},
{
name: "Avg Latency",
x: 12, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "avg", valueExpression: "Duration" }]
}
},
{
name: "P99 Latency",
x: 18, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.99 }]
}
},
{
name: "Request Volume Over Time",
x: 0, y: 4, w: 12, h: 4,
config: {
displayType: "line",
sourceId: "${traceSourceId}",
select: [{ aggFn: "count" }],
groupBy: "ResourceAttributes['service.name']"
}
},
{
name: "Error Rate Over Time",
x: 12, y: 4, w: 12, h: 4,
config: {
displayType: "line",
sourceId: "${traceSourceId}",
select: [
{ aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", alias: "Errors" },
{ aggFn: "count", alias: "Total" }
],
asRatio: true
}
},
{
name: "Top Endpoints",
x: 0, y: 8, w: 24, h: 6,
config: {
displayType: "table",
sourceId: "${traceSourceId}",
groupBy: "SpanName",
select: [
{ aggFn: "count", alias: "Requests" },
{ aggFn: "avg", valueExpression: "Duration", alias: "Avg Duration" },
{ aggFn: "quantile", valueExpression: "Duration", level: 0.95, alias: "P95" },
{ aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", alias: "Errors" }
]
}
}
]
}`;
examples['error_tracking'] = `
== ERROR TRACKING ==
Focus on errors: volume, distribution, and raw error logs.
{
name: "Error Tracking",
tags: ["errors"],
tiles: [
{
name: "Total Errors",
x: 0, y: 0, w: 8, h: 4,
config: {
displayType: "number",
sourceId: "${logSourceId}",
select: [{ aggFn: "count", where: "SeverityText:error OR SeverityText:fatal" }]
}
},
{
name: "Errors Over Time by Service",
x: 0, y: 4, w: 12, h: 4,
config: {
displayType: "line",
sourceId: "${logSourceId}",
select: [{ aggFn: "count", where: "SeverityText:error OR SeverityText:fatal" }],
groupBy: "ResourceAttributes['service.name']"
}
},
{
name: "Error Breakdown by Service",
x: 12, y: 4, w: 12, h: 4,
config: {
displayType: "pie",
sourceId: "${logSourceId}",
select: [{ aggFn: "count", where: "SeverityText:error OR SeverityText:fatal" }],
groupBy: "ResourceAttributes['service.name']"
}
},
{
name: "Error Logs",
x: 0, y: 8, w: 24, h: 6,
config: {
displayType: "search",
sourceId: "${logSourceId}",
where: "SeverityText:error OR SeverityText:fatal"
}
}
]
}`;
examples['latency'] = `
== LATENCY MONITORING ==
Track response times with percentile breakdowns and slow endpoint identification.
{
name: "Latency Monitoring",
tags: ["latency", "performance"],
tiles: [
{
name: "P50 Latency",
x: 0, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.5 }]
}
},
{
name: "P95 Latency",
x: 6, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.95 }]
}
},
{
name: "P99 Latency",
x: 12, y: 0, w: 6, h: 4,
config: {
displayType: "number",
sourceId: "${traceSourceId}",
select: [{ aggFn: "quantile", valueExpression: "Duration", level: 0.99 }]
}
},
{
name: "Latency Percentiles Over Time",
x: 0, y: 4, w: 24, h: 4,
config: {
displayType: "line",
sourceId: "${traceSourceId}",
select: [
{ aggFn: "quantile", valueExpression: "Duration", level: 0.5, alias: "P50" },
{ aggFn: "quantile", valueExpression: "Duration", level: 0.95, alias: "P95" },
{ aggFn: "quantile", valueExpression: "Duration", level: 0.99, alias: "P99" }
]
}
},
{
name: "Latency by Service",
x: 0, y: 8, w: 12, h: 4,
config: {
displayType: "stacked_bar",
sourceId: "${traceSourceId}",
select: [{ aggFn: "avg", valueExpression: "Duration" }],
groupBy: "ResourceAttributes['service.name']"
}
},
{
name: "Slowest Endpoints",
x: 12, y: 8, w: 12, h: 6,
config: {
displayType: "table",
sourceId: "${traceSourceId}",
groupBy: "SpanName",
select: [
{ aggFn: "quantile", valueExpression: "Duration", level: 0.95, alias: "P95 Duration" },
{ aggFn: "avg", valueExpression: "Duration", alias: "Avg Duration" },
{ aggFn: "count", alias: "Request Count" }
]
}
}
]
}`;
examples['log_analysis'] = `
== LOG ANALYSIS ==
Analyze log volume, severity distribution, and browse log events.
{
name: "Log Analysis",
tags: ["logs"],
tiles: [
{
name: "Total Log Events",
x: 0, y: 0, w: 8, h: 4,
config: {
displayType: "number",
sourceId: "${logSourceId}",
select: [{ aggFn: "count" }]
}
},
{
name: "Log Volume by Severity",
x: 0, y: 4, w: 12, h: 4,
config: {
displayType: "stacked_bar",
sourceId: "${logSourceId}",
select: [{ aggFn: "count" }],
groupBy: "SeverityText"
}
},
{
name: "Severity Breakdown",
x: 12, y: 4, w: 12, h: 4,
config: {
displayType: "pie",
sourceId: "${logSourceId}",
select: [{ aggFn: "count" }],
groupBy: "SeverityText"
}
},
{
name: "Top Services by Log Volume",
x: 0, y: 8, w: 12, h: 6,
config: {
displayType: "table",
sourceId: "${logSourceId}",
groupBy: "ResourceAttributes['service.name']",
select: [
{ aggFn: "count", alias: "Log Count" },
{ aggFn: "count", where: "SeverityText:error OR SeverityText:fatal", alias: "Error Count" }
]
}
},
{
name: "Recent Logs",
x: 12, y: 8, w: 12, h: 6,
config: {
displayType: "search",
sourceId: "${logSourceId}"
}
}
]
}`;
examples['infrastructure_sql'] = `
== INFRASTRUCTURE MONITORING (Raw SQL) ==
Advanced dashboard using raw SQL tiles for custom ClickHouse queries.
Use this pattern when you need JOINs, CTEs, or queries the builder cannot express.
{
name: "Infrastructure (SQL)",
tags: ["infrastructure", "sql"],
tiles: [
{
name: "Log Ingestion Rate Over Time",
x: 0, y: 0, w: 12, h: 4,
config: {
configType: "sql",
displayType: "line",
connectionId: "${connectionId}",
sqlTemplate: "SELECT $__timeInterval(Timestamp) AS ts, count() AS logs_per_interval FROM otel_logs WHERE $__timeFilter(Timestamp) GROUP BY ts ORDER BY ts"
}
},
{
name: "Top 20 Services by Span Count",
x: 12, y: 0, w: 12, h: 4,
config: {
configType: "sql",
displayType: "table",
connectionId: "${connectionId}",
sqlTemplate: "SELECT ServiceName, count() AS span_count, avg(Duration) AS avg_duration FROM otel_traces WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64}) GROUP BY ServiceName ORDER BY span_count DESC LIMIT 20"
}
},
{
name: "Error Rate by Service (SQL)",
x: 0, y: 4, w: 24, h: 4,
config: {
configType: "sql",
displayType: "line",
connectionId: "${connectionId}",
sqlTemplate: "SELECT $__timeInterval(Timestamp) AS ts, ServiceName, countIf(StatusCode = 'STATUS_CODE_ERROR') / count() AS error_rate FROM otel_traces WHERE $__timeFilter(Timestamp) GROUP BY ServiceName, ts ORDER BY ts"
}
}
]
}
SQL TEMPLATE REFERENCE:
Macros (expanded before execution):
$__timeFilter(col) col >= <start> AND col <= <end> (DateTime)
$__timeFilter_ms(col) same with DateTime64 millisecond precision
$__dateFilter(col) same with Date precision
$__timeInterval(col) time bucket: toStartOfInterval(toDateTime(col), INTERVAL ...)
$__timeInterval_ms(col) same with millisecond precision
$__fromTime / $__toTime start/end as DateTime values
$__fromTime_ms / $__toTime_ms start/end as DateTime64 values
$__interval_s raw interval in seconds
$__filters dashboard filter conditions (resolves to 1=1 when none)
Query parameters:
{startDateMilliseconds:Int64} start of date range in milliseconds
{endDateMilliseconds:Int64} end of date range in milliseconds
{intervalSeconds:Int64} time bucket size in seconds
{intervalMilliseconds:Int64} time bucket size in milliseconds
Available parameters by displayType:
line / stacked_bar startDate, endDate, interval (all available)
table / number / pie startDate, endDate only (no interval)`;
if (pattern) {
const key = pattern.toLowerCase().replace(/[\s-]+/g, '_');
const matched = Object.entries(examples).find(([k]) => k === key);
if (matched) {
return `Dashboard example for pattern: ${pattern}\n\nReplace sourceId/connectionId values with real IDs from hyperdx_list_sources.\nNOTE: Column names below (Duration, StatusCode, SpanName, etc.) are defaults for the standard schema. Call hyperdx_list_sources to get the actual column names for your sources.\n${matched[1]}`;
}
return (
`No example found for pattern "${pattern}". Available patterns: ${Object.keys(examples).join(', ')}\n\n` +
`Showing all examples below.\n\n` +
Object.values(examples).join('\n')
);
}
return (
`Complete dashboard examples for common observability patterns.\n` +
`Replace sourceId/connectionId values with real IDs from hyperdx_list_sources.\n` +
`NOTE: Column names below (Duration, StatusCode, SpanName, etc.) are defaults for the standard schema. Call hyperdx_list_sources to get the actual column names for your sources.\n\n` +
`Available patterns: ${Object.keys(examples).join(', ')}\n` +
Object.values(examples).join('\n')
);
}
export function buildQueryGuidePrompt(): string {
return `Reference guide for writing queries with HyperDX MCP tools (hyperdx_query and hyperdx_save_dashboard).
== AGGREGATION FUNCTIONS (aggFn) ==
count Count matching rows. Does NOT take a valueExpression.
sum Sum of a numeric column. Requires valueExpression.
avg Average of a numeric column. Requires valueExpression.
min Minimum value. Requires valueExpression.
max Maximum value. Requires valueExpression.
count_distinct Count of unique values. Requires valueExpression.
quantile Percentile value. Requires valueExpression AND level (0.5, 0.9, 0.95, or 0.99).
last_value Most recent value of a column. Requires valueExpression.
none Pass a raw expression unchanged. Requires valueExpression.
Examples:
{ aggFn: "count" }
{ aggFn: "avg", valueExpression: "Duration" }
{ aggFn: "quantile", valueExpression: "Duration", level: 0.95 }
{ aggFn: "count_distinct", valueExpression: "ResourceAttributes['service.name']" }
{ aggFn: "sum", valueExpression: "Duration", where: "StatusCode:STATUS_CODE_ERROR" }
== COLUMN NAMING ==
Top-level columns (PascalCase defaults use directly in valueExpression and groupBy):
Duration, StatusCode, SpanName, ServiceName, Body, SeverityText,
Timestamp, TraceId, SpanId, SpanKind, ParentSpanId
NOTE: These are the defaults for the standard HyperDX schema. Custom sources may
use different column names. Always verify with hyperdx_list_sources, which returns
the real column names and keyColumns expressions for each source.
Map-type columns (bracket syntax access keys via ['key']):
SpanAttributes['http.method']
SpanAttributes['http.route']
SpanAttributes['http.status_code']
ResourceAttributes['service.name']
ResourceAttributes['deployment.environment']
IMPORTANT: Always use bracket syntax for Map-type columns. Never use dot notation for Maps.
Correct: SpanAttributes['http.method']
Incorrect: SpanAttributes.http.method
JSON-type columns (dot notation access nested keys via dot path):
JsonColumn.key.subkey
NOTE: Check the jsType field returned by hyperdx_list_sources to determine
whether a column is Map (use brackets) or JSON (use dots).
== LUCENE FILTER SYNTAX ==
Used in the "where" field of select items and search tiles.
Basic match: level:error
AND: service.name:api AND http.status_code:>=500
OR: level:error OR level:fatal
NOT: NOT level:debug
Wildcard: service.name:front*
Phrase: Body:"connection refused"
Exists: _exists_:http.route
Range (numeric): Duration:>1000000000
Range (inclusive): http.status_code:[400 TO 499]
Grouped: (level:error OR level:fatal) AND service.name:api
NOTE: In Lucene filters, use dot notation for attribute keys (service.name, http.method).
This is different from valueExpression/groupBy which requires bracket syntax (SpanAttributes['http.method']).
== SQL FILTER SYNTAX ==
Alternative to Lucene. Set whereLanguage: "sql" when using SQL syntax.
Basic: SeverityText = 'error'
AND/OR: ServiceName = 'api' AND StatusCode = 'STATUS_CODE_ERROR'
IN: ServiceName IN ('api', 'web', 'worker')
LIKE: Body LIKE '%timeout%'
Comparison: Duration > 1000000000
Map access: SpanAttributes['http.status_code'] = '500'
== RAW SQL TEMPLATES ==
For configType: "sql" tiles, write ClickHouse SQL with template macros:
MACROS (expanded before execution):
$__timeFilter(col) col >= <start> AND col <= <end>
$__timeFilter_ms(col) same with DateTime64 millisecond precision
$__dateFilter(col) same with Date precision
$__dateTimeFilter(d, t) filters on both Date and DateTime columns
$__timeInterval(col) time bucket expression for GROUP BY
$__timeInterval_ms(col) same with millisecond precision
$__fromTime / $__toTime start/end as DateTime values
$__fromTime_ms / $__toTime_ms start/end as DateTime64 values
$__interval_s raw interval in seconds (for arithmetic)
$__filters dashboard filter conditions (1=1 when none)
QUERY PARAMETERS (ClickHouse parameterized syntax):
{startDateMilliseconds:Int64}
{endDateMilliseconds:Int64}
{intervalSeconds:Int64}
{intervalMilliseconds:Int64}
TIME-SERIES EXAMPLE (line / stacked_bar):
SELECT
$__timeInterval(Timestamp) AS ts,
ServiceName,
count() AS requests
FROM otel_traces
WHERE $__timeFilter(Timestamp)
GROUP BY ServiceName, ts
ORDER BY ts
TABLE EXAMPLE:
SELECT
ServiceName,
count() AS request_count,
avg(Duration) AS avg_duration,
quantile(0.95)(Duration) AS p95_duration
FROM otel_traces
WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})
AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
GROUP BY ServiceName
ORDER BY request_count DESC
LIMIT 50
IMPORTANT: Always include a LIMIT clause in table/number/pie SQL queries.
== PER-TILE TYPE CONSTRAINTS ==
number Exactly 1 select item. No groupBy.
pie Exactly 1 select item. groupBy defines the slices.
line 1-20 select items. Optional groupBy splits into series.
stacked_bar 1-20 select items. Optional groupBy splits into stacks.
table 1-20 select items. Optional groupBy defines row groups.
search No select items (select is a column list string). where is the filter.
markdown No select items. Set markdown field with content.
== asRatio ==
Set asRatio: true on line/stacked_bar/table tiles with exactly 2 select items
to plot the first as a ratio of the second. Useful for error rates:
select: [
{ aggFn: "count", where: "StatusCode:STATUS_CODE_ERROR", alias: "Errors" },
{ aggFn: "count", alias: "Total" }
],
asRatio: true
== COMMON MISTAKES ==
1. Using valueExpression with aggFn "count"
Wrong: { aggFn: "count", valueExpression: "Duration" }
Correct: { aggFn: "count" }
2. Forgetting valueExpression for non-count aggFns
Wrong: { aggFn: "avg" }
Correct: { aggFn: "avg", valueExpression: "Duration" }
3. Using dot notation for Map-type attributes in valueExpression/groupBy
Wrong: groupBy: "SpanAttributes.http.method"
Correct: groupBy: "SpanAttributes['http.method']"
NOTE: JSON-type columns DO use dot notation. Check jsType from hyperdx_list_sources.
4. Multiple select items on number/pie tiles
Wrong: displayType: "number", select: [{ aggFn: "count" }, { aggFn: "avg", ... }]
Correct: displayType: "number", select: [{ aggFn: "count" }]
5. Missing level for quantile
Wrong: { aggFn: "quantile", valueExpression: "Duration" }
Correct: { aggFn: "quantile", valueExpression: "Duration", level: 0.95 }
6. Forgetting to validate tiles after saving
Always call hyperdx_query_tile after hyperdx_save_dashboard to verify each tile returns data.
7. Using sourceId with SQL tiles or connectionId with builder tiles
Builder tiles (line, table, etc.) use sourceId.
SQL tiles (configType: "sql") use connectionId.
8. Assuming StatusCode or SeverityText values
Values like STATUS_CODE_ERROR, Ok, error, fatal vary by deployment.
Always call hyperdx_list_sources and inspect real keyValues from the source
before writing filters that depend on these columns.`;
}

View file

@ -0,0 +1,48 @@
// ─── Source/connection summary helpers ───────────────────────────────────────
export function buildSourceSummary(
sources: { _id: unknown; name: string; kind: string; connection: unknown }[],
connections: { _id: unknown; name: string }[],
): string {
if (sources.length === 0 && connections.length === 0) {
return 'No sources or connections found. Call hyperdx_list_sources to discover available data.';
}
const lines: string[] = [];
if (sources.length > 0) {
lines.push('AVAILABLE SOURCES (use sourceId with builder tiles):');
for (const s of sources) {
lines.push(
` - "${s.name}" (${s.kind}) — sourceId: "${s._id}", connectionId: "${s.connection}"`,
);
}
}
if (connections.length > 0) {
lines.push('');
lines.push(
'AVAILABLE CONNECTIONS (use connectionId with raw SQL tiles only):',
);
for (const c of connections) {
lines.push(` - "${c.name}" — connectionId: "${c._id}"`);
}
}
return lines.join('\n');
}
export function getFirstSourceId(
sources: { _id: unknown; kind: string }[],
preferredKind?: string,
): string {
const preferred = preferredKind
? sources.find(s => s.kind === preferredKind)
: undefined;
const source = preferred ?? sources[0];
return source ? String(source._id) : '<SOURCE_ID>';
}
export function getFirstConnectionId(connections: { _id: unknown }[]): string {
return connections[0] ? String(connections[0]._id) : '<CONNECTION_ID>';
}

View file

@ -0,0 +1,195 @@
import { z } from 'zod';
import { getConnectionsByTeam } from '@/controllers/connection';
import { getSources } from '@/controllers/sources';
import logger from '@/utils/logger';
import type { PromptDefinition } from '../../tools/types';
import {
buildCreateDashboardPrompt,
buildDashboardExamplesPrompt,
buildQueryGuidePrompt,
} from './content';
import {
buildSourceSummary,
getFirstConnectionId,
getFirstSourceId,
} from './helpers';
const dashboardPrompts: PromptDefinition = (server, context) => {
const { teamId } = context;
// ── create_dashboard ──────────────────────────────────────────────────────
server.registerPrompt(
'create_dashboard',
{
title: 'Create a Dashboard',
description:
'Create a HyperDX dashboard with the MCP tools. ' +
'Follow the recommended workflow, pick tile types, write queries, ' +
'and validate results — using your real data sources.',
argsSchema: {
description: z
.string()
.optional()
.describe(
'What the dashboard should monitor (e.g. "API error rates and latency")',
),
},
},
async ({ description }) => {
let sourceSummary: string;
let traceSourceId: string;
let logSourceId: string;
try {
const [sources, connections] = await Promise.all([
getSources(teamId),
getConnectionsByTeam(teamId),
]);
sourceSummary = buildSourceSummary(
sources.map(s => ({
_id: s._id,
name: s.name,
kind: s.kind,
connection: s.connection,
})),
connections.map(c => ({ _id: c._id, name: c.name })),
);
traceSourceId = getFirstSourceId(
sources.map(s => ({ _id: s._id, kind: s.kind })),
'trace',
);
logSourceId = getFirstSourceId(
sources.map(s => ({ _id: s._id, kind: s.kind })),
'log',
);
} catch (e) {
logger.warn(
{ teamId, error: e },
'Failed to fetch sources for create_dashboard prompt',
);
sourceSummary =
'Could not fetch sources. Call hyperdx_list_sources to discover available data.';
traceSourceId = '<SOURCE_ID>';
logSourceId = '<SOURCE_ID>';
}
return {
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: buildCreateDashboardPrompt(
sourceSummary,
traceSourceId,
logSourceId,
description,
),
},
},
],
};
},
);
// ── dashboard_examples ────────────────────────────────────────────────────
server.registerPrompt(
'dashboard_examples',
{
title: 'Dashboard Examples',
description:
'Get copy-paste-ready dashboard examples for common observability patterns: ' +
'service_overview, error_tracking, latency, log_analysis, infrastructure_sql.',
argsSchema: {
pattern: z
.string()
.optional()
.describe(
'Filter to a specific pattern: service_overview, error_tracking, latency, log_analysis, infrastructure_sql',
),
},
},
async ({ pattern }) => {
let traceSourceId: string;
let logSourceId: string;
let connectionId: string;
try {
const [sources, connections] = await Promise.all([
getSources(teamId),
getConnectionsByTeam(teamId),
]);
traceSourceId = getFirstSourceId(
sources.map(s => ({ _id: s._id, kind: s.kind })),
'trace',
);
logSourceId = getFirstSourceId(
sources.map(s => ({ _id: s._id, kind: s.kind })),
'log',
);
connectionId = getFirstConnectionId(
connections.map(c => ({ _id: c._id })),
);
} catch (e) {
logger.warn(
{ teamId, error: e },
'Failed to fetch sources for dashboard_examples prompt',
);
traceSourceId = '<TRACE_SOURCE_ID>';
logSourceId = '<LOG_SOURCE_ID>';
connectionId = '<CONNECTION_ID>';
}
return {
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: buildDashboardExamplesPrompt(
traceSourceId,
logSourceId,
connectionId,
pattern,
),
},
},
],
};
},
);
// ── query_guide ───────────────────────────────────────────────────────────
server.registerPrompt(
'query_guide',
{
title: 'Query Writing Guide',
description:
'Look up HyperDX query syntax: aggregation functions, ' +
'Lucene/SQL filters, raw SQL macros, column naming, ' +
'per-tile constraints, and common mistakes.',
},
async () => {
return {
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: buildQueryGuidePrompt(),
},
},
],
};
},
);
};
export default dashboardPrompts;

View file

@ -0,0 +1,63 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import mongoose from 'mongoose';
import { z } from 'zod';
import { deleteDashboard } from '@/controllers/dashboard';
import Dashboard from '@/models/dashboard';
import { withToolTracing } from '../../utils/tracing';
import type { McpContext } from '../types';
export function registerDeleteDashboard(
server: McpServer,
context: McpContext,
): void {
const { teamId } = context;
server.registerTool(
'hyperdx_delete_dashboard',
{
title: 'Delete Dashboard',
description:
'Permanently delete a dashboard by ID. Also removes any alerts attached to its tiles. ' +
'Use hyperdx_get_dashboard (without an ID) to list available dashboard IDs.',
inputSchema: z.object({
id: z.string().describe('Dashboard ID to delete.'),
}),
},
withToolTracing(
'hyperdx_delete_dashboard',
context,
async ({ id: dashboardId }) => {
if (!mongoose.Types.ObjectId.isValid(dashboardId)) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Invalid dashboard ID' }],
};
}
const existing = await Dashboard.findOne({
_id: dashboardId,
team: teamId,
}).lean();
if (!existing) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Dashboard not found' }],
};
}
await deleteDashboard(dashboardId, new mongoose.Types.ObjectId(teamId));
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({ deleted: true, id: dashboardId }, null, 2),
},
],
};
},
),
);
}

View file

@ -0,0 +1,87 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import mongoose from 'mongoose';
import { z } from 'zod';
import * as config from '@/config';
import { getDashboards } from '@/controllers/dashboard';
import Dashboard from '@/models/dashboard';
import { convertToExternalDashboard } from '@/routers/external-api/v2/utils/dashboards';
import { withToolTracing } from '../../utils/tracing';
import type { McpContext } from '../types';
export function registerGetDashboard(
server: McpServer,
context: McpContext,
): void {
const { teamId } = context;
const frontendUrl = config.FRONTEND_URL;
server.registerTool(
'hyperdx_get_dashboard',
{
title: 'Get Dashboard(s)',
description:
'Without an ID: list all dashboards (returns IDs, names, tags). ' +
'With an ID: get full dashboard detail including all tiles and configuration.',
inputSchema: z.object({
id: z
.string()
.optional()
.describe(
'Dashboard ID. Omit to list all dashboards, provide to get full detail.',
),
}),
},
withToolTracing('hyperdx_get_dashboard', context, async ({ id }) => {
if (!id) {
const dashboards = await getDashboards(
new mongoose.Types.ObjectId(teamId),
);
const output = dashboards.map(d => ({
id: d._id.toString(),
name: d.name,
tags: d.tags,
...(frontendUrl ? { url: `${frontendUrl}/dashboards/${d._id}` } : {}),
}));
return {
content: [
{ type: 'text' as const, text: JSON.stringify(output, null, 2) },
],
};
}
if (!mongoose.Types.ObjectId.isValid(id)) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Invalid dashboard ID' }],
};
}
const dashboard = await Dashboard.findOne({ _id: id, team: teamId });
if (!dashboard) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Dashboard not found' }],
};
}
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
...convertToExternalDashboard(dashboard),
...(frontendUrl
? { url: `${frontendUrl}/dashboards/${dashboard._id}` }
: {}),
},
null,
2,
),
},
],
};
}),
);
}

View file

@ -0,0 +1,23 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { McpContext, ToolDefinition } from '../types';
import { registerDeleteDashboard } from './deleteDashboard';
import { registerGetDashboard } from './getDashboard';
import { registerListSources } from './listSources';
import { registerQueryTile } from './queryTile';
import { registerSaveDashboard } from './saveDashboard';
export * from './schemas';
const dashboardsTools: ToolDefinition = (
server: McpServer,
context: McpContext,
) => {
registerListSources(server, context);
registerGetDashboard(server, context);
registerSaveDashboard(server, context);
registerDeleteDashboard(server, context);
registerQueryTile(server, context);
};
export default dashboardsTools;

View file

@ -0,0 +1,183 @@
import {
convertCHDataTypeToJSType,
filterColumnMetaByType,
JSDataType,
} from '@hyperdx/common-utils/dist/clickhouse';
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import {
getConnectionById,
getConnectionsByTeam,
} from '@/controllers/connection';
import { getSources } from '@/controllers/sources';
import logger from '@/utils/logger';
import { withToolTracing } from '../../utils/tracing';
import type { McpContext } from '../types';
export function registerListSources(
server: McpServer,
context: McpContext,
): void {
const { teamId } = context;
server.registerTool(
'hyperdx_list_sources',
{
title: 'List Sources & Connections',
description:
'List all data sources (logs, metrics, traces) and database connections available to this team. ' +
'Returns source IDs (use as sourceId in hyperdx_query and dashboard tiles) and ' +
'connection IDs (use as connectionId for advanced raw SQL queries). ' +
'Each source includes its full column schema and sampled attribute keys from map columns ' +
'(e.g. SpanAttributes, ResourceAttributes). ' +
'Column names are PascalCase (e.g. Duration, not duration). ' +
"Map attributes must be accessed via bracket syntax: SpanAttributes['key'].\n\n" +
'NOTE: For most queries, use source IDs with the builder display types. ' +
'Connection IDs are only needed for advanced raw SQL queries (displayType "sql").',
inputSchema: z.object({}),
},
withToolTracing('hyperdx_list_sources', context, async () => {
const [sources, connections] = await Promise.all([
getSources(teamId.toString()),
getConnectionsByTeam(teamId.toString()),
]);
const sourcesWithSchema = await Promise.all(
sources.map(async s => {
const meta: Record<string, unknown> = {
id: s._id.toString(),
name: s.name,
kind: s.kind,
connectionId: s.connection.toString(),
timestampColumn: s.timestampValueExpression,
};
if ('eventAttributesExpression' in s && s.eventAttributesExpression) {
meta.eventAttributesColumn = s.eventAttributesExpression;
}
if (
'resourceAttributesExpression' in s &&
s.resourceAttributesExpression
) {
meta.resourceAttributesColumn = s.resourceAttributesExpression;
}
if (s.kind === SourceKind.Trace) {
meta.keyColumns = {
spanName: s.spanNameExpression,
duration: s.durationExpression,
durationPrecision: s.durationPrecision,
statusCode: s.statusCodeExpression,
serviceName: s.serviceNameExpression,
traceId: s.traceIdExpression,
spanId: s.spanIdExpression,
};
} else if (s.kind === SourceKind.Log) {
meta.keyColumns = {
body: s.bodyExpression,
serviceName: s.serviceNameExpression,
severityText: s.severityTextExpression,
traceId: s.traceIdExpression,
};
} else if (s.kind === SourceKind.Metric) {
meta.metricTables = s.metricTables;
}
// Skip column schema fetch for sources without a table (e.g. metrics
// sources store their tables in metricTables, not from.tableName).
if (s.from.tableName) {
try {
const connection = await getConnectionById(
teamId.toString(),
s.connection.toString(),
true,
);
if (!connection) {
throw new Error(`Connection not found for source ${s._id}`);
}
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});
const metadata = getMetadata(clickhouseClient);
const columns = await metadata.getColumns({
databaseName: s.from.databaseName,
tableName: s.from.tableName,
connectionId: s.connection.toString(),
});
meta.columns = columns.map(c => ({
name: c.name,
type: c.type,
jsType: convertCHDataTypeToJSType(c.type),
}));
const mapColumns = filterColumnMetaByType(columns, [
JSDataType.Map,
]);
const mapKeysResults: Record<string, string[]> = {};
await Promise.all(
(mapColumns ?? []).map(async col => {
try {
const keys = await metadata.getMapKeys({
databaseName: s.from.databaseName,
tableName: s.from.tableName,
column: col.name,
maxKeys: 50,
connectionId: s.connection.toString(),
});
mapKeysResults[col.name] = keys;
} catch {
// Skip columns where key sampling fails
}
}),
);
if (Object.keys(mapKeysResults).length > 0) {
meta.mapAttributeKeys = mapKeysResults;
}
} catch (e) {
logger.warn(
{ teamId, sourceId: s._id, error: e },
'Failed to fetch schema for source',
);
}
}
return meta;
}),
);
const output = {
sources: sourcesWithSchema,
connections: connections.map(c => ({
id: c._id.toString(),
name: c.name,
})),
usage: {
topLevelColumns:
'Use directly in valueExpression/groupBy with PascalCase: Duration, StatusCode, SpanName',
mapAttributes:
"Use bracket syntax: SpanAttributes['http.method'], ResourceAttributes['service.name']",
sourceIds:
'Use sourceId with builder display types (line, stacked_bar, table, number, pie, search) for standard queries',
connectionIds:
'ADVANCED: Use connectionId only with raw SQL queries (displayType "sql" or configType "sql"). ' +
'Raw SQL is for advanced use cases like JOINs, sub-queries, or querying tables not registered as sources.',
},
};
return {
content: [
{ type: 'text' as const, text: JSON.stringify(output, null, 2) },
],
};
}),
);
}

View file

@ -0,0 +1,97 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import mongoose from 'mongoose';
import { z } from 'zod';
import Dashboard from '@/models/dashboard';
import { convertToExternalDashboard } from '@/routers/external-api/v2/utils/dashboards';
import { withToolTracing } from '../../utils/tracing';
import { parseTimeRange, runConfigTile } from '../query/helpers';
import type { McpContext } from '../types';
export function registerQueryTile(
server: McpServer,
context: McpContext,
): void {
const { teamId } = context;
server.registerTool(
'hyperdx_query_tile',
{
title: 'Query a Dashboard Tile',
description:
'Execute the query for a specific tile on an existing dashboard. ' +
'Useful for validating that a tile returns data or for spot-checking results ' +
'without rebuilding the query from scratch. ' +
'Use hyperdx_get_dashboard with an ID to find tile IDs.',
inputSchema: z.object({
dashboardId: z.string().describe('Dashboard ID.'),
tileId: z
.string()
.describe(
'Tile ID within the dashboard. ' +
'Obtain from hyperdx_get_dashboard.',
),
startTime: z
.string()
.optional()
.describe(
'Start of the query window as ISO 8601. Default: 15 minutes ago. ' +
'If results are empty, try a wider range (e.g. 24 hours).',
),
endTime: z
.string()
.optional()
.describe('End of the query window as ISO 8601. Default: now.'),
}),
},
withToolTracing(
'hyperdx_query_tile',
context,
async ({ dashboardId, tileId, startTime, endTime }) => {
const timeRange = parseTimeRange(startTime, endTime);
if ('error' in timeRange) {
return {
isError: true,
content: [{ type: 'text' as const, text: timeRange.error }],
};
}
const { startDate, endDate } = timeRange;
if (!mongoose.Types.ObjectId.isValid(dashboardId)) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Invalid dashboard ID' }],
};
}
const dashboard = await Dashboard.findOne({
_id: dashboardId,
team: teamId,
});
if (!dashboard) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Dashboard not found' }],
};
}
const externalDashboard = convertToExternalDashboard(dashboard);
const tile = externalDashboard.tiles.find(t => t.id === tileId);
if (!tile) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: `Tile not found: ${tileId}. Available tile IDs: ${externalDashboard.tiles.map(t => t.id).join(', ')}`,
},
],
};
}
return runConfigTile(teamId.toString(), tile, startDate, endDate);
},
),
);
}

View file

@ -0,0 +1,338 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { uniq } from 'lodash';
import mongoose from 'mongoose';
import { z } from 'zod';
import * as config from '@/config';
import Dashboard from '@/models/dashboard';
import {
cleanupDashboardAlerts,
convertExternalFiltersToInternal,
convertExternalTilesToInternal,
convertToExternalDashboard,
createDashboardBodySchema,
getMissingConnections,
getMissingSources,
resolveSavedQueryLanguage,
updateDashboardBodySchema,
} from '@/routers/external-api/v2/utils/dashboards';
import type { ExternalDashboardTileWithId } from '@/utils/zod';
import { withToolTracing } from '../../utils/tracing';
import type { McpContext } from '../types';
import { mcpTilesParam } from './schemas';
export function registerSaveDashboard(
server: McpServer,
context: McpContext,
): void {
const { teamId } = context;
const frontendUrl = config.FRONTEND_URL;
server.registerTool(
'hyperdx_save_dashboard',
{
title: 'Create or Update Dashboard',
description:
'Create a new dashboard (omit id) or update an existing one (provide id). ' +
'Call hyperdx_list_sources first to obtain sourceId and connectionId values. ' +
'IMPORTANT: After saving a dashboard, always run hyperdx_query_tile on each tile ' +
'to confirm the queries work and return expected data. Tiles can silently fail ' +
'due to incorrect filter syntax, missing attributes, or wrong column names.',
inputSchema: z.object({
id: z
.string()
.optional()
.describe(
'Dashboard ID. Omit to create a new dashboard, provide to update an existing one.',
),
name: z.string().describe('Dashboard name'),
tiles: mcpTilesParam,
tags: z.array(z.string()).optional().describe('Dashboard tags'),
}),
},
withToolTracing(
'hyperdx_save_dashboard',
context,
async ({ id: dashboardId, name, tiles: inputTiles, tags }) => {
if (!dashboardId) {
return createDashboard({
teamId,
frontendUrl,
name,
inputTiles,
tags,
});
}
return updateDashboard({
teamId,
frontendUrl,
dashboardId,
name,
inputTiles,
tags,
});
},
),
);
}
// ─── Create helper ────────────────────────────────────────────────────────────
async function createDashboard({
teamId,
frontendUrl,
name,
inputTiles,
tags,
}: {
teamId: string;
frontendUrl: string | undefined;
name: string;
inputTiles: unknown[];
tags: string[] | undefined;
}) {
const parsed = createDashboardBodySchema.safeParse({
name,
tiles: inputTiles,
tags,
});
if (!parsed.success) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: `Validation error: ${JSON.stringify(parsed.error.errors)}`,
},
],
};
}
const { tiles, filters } = parsed.data;
const tilesWithId = tiles as ExternalDashboardTileWithId[];
const [missingSources, missingConnections] = await Promise.all([
getMissingSources(teamId, tilesWithId, filters),
getMissingConnections(teamId, tilesWithId),
]);
if (missingSources.length > 0) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: `Could not find source IDs: ${missingSources.join(', ')}`,
},
],
};
}
if (missingConnections.length > 0) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: `Could not find connection IDs: ${missingConnections.join(', ')}`,
},
],
};
}
const internalTiles = convertExternalTilesToInternal(tilesWithId);
const filtersWithIds = convertExternalFiltersToInternal(filters ?? []);
const normalizedSavedQueryLanguage = resolveSavedQueryLanguage({
savedQuery: undefined,
savedQueryLanguage: undefined,
});
const newDashboard = await new Dashboard({
name: parsed.data.name,
tiles: internalTiles,
tags: tags && uniq(tags),
filters: filtersWithIds,
savedQueryLanguage: normalizedSavedQueryLanguage,
savedFilterValues: parsed.data.savedFilterValues,
team: teamId,
}).save();
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
...convertToExternalDashboard(newDashboard),
...(frontendUrl
? { url: `${frontendUrl}/dashboards/${newDashboard._id}` }
: {}),
hint: 'Use hyperdx_query to test individual tile queries before viewing the dashboard.',
},
null,
2,
),
},
],
};
}
// ─── Update helper ────────────────────────────────────────────────────────────
async function updateDashboard({
teamId,
frontendUrl,
dashboardId,
name,
inputTiles,
tags,
}: {
teamId: string;
frontendUrl: string | undefined;
dashboardId: string;
name: string;
inputTiles: unknown[];
tags: string[] | undefined;
}) {
if (!mongoose.Types.ObjectId.isValid(dashboardId)) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Invalid dashboard ID' }],
};
}
const parsed = updateDashboardBodySchema.safeParse({
name,
tiles: inputTiles,
tags,
});
if (!parsed.success) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: `Validation error: ${JSON.stringify(parsed.error.errors)}`,
},
],
};
}
const { tiles, filters } = parsed.data;
const tilesWithId = tiles as ExternalDashboardTileWithId[];
const [missingSources, missingConnections] = await Promise.all([
getMissingSources(teamId, tilesWithId, filters),
getMissingConnections(teamId, tilesWithId),
]);
if (missingSources.length > 0) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: `Could not find source IDs: ${missingSources.join(', ')}`,
},
],
};
}
if (missingConnections.length > 0) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: `Could not find connection IDs: ${missingConnections.join(', ')}`,
},
],
};
}
const existingDashboard = await Dashboard.findOne(
{ _id: dashboardId, team: teamId },
{ tiles: 1, filters: 1 },
).lean();
if (!existingDashboard) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Dashboard not found' }],
};
}
const existingTileIds = new Set(
(existingDashboard.tiles ?? []).map((t: { id: string }) => t.id),
);
const existingFilterIds = new Set(
(existingDashboard.filters ?? []).map((f: { id: string }) => f.id),
);
const internalTiles = convertExternalTilesToInternal(
tilesWithId,
existingTileIds,
);
const setPayload: Record<string, unknown> = {
name,
tiles: internalTiles,
tags: tags && uniq(tags),
};
if (filters !== undefined) {
setPayload.filters = convertExternalFiltersToInternal(
filters,
existingFilterIds,
);
}
const normalizedSavedQueryLanguage = resolveSavedQueryLanguage({
savedQuery: undefined,
savedQueryLanguage: undefined,
});
if (normalizedSavedQueryLanguage !== undefined) {
setPayload.savedQueryLanguage = normalizedSavedQueryLanguage;
}
if (parsed.data.savedFilterValues !== undefined) {
setPayload.savedFilterValues = parsed.data.savedFilterValues;
}
const updatedDashboard = await Dashboard.findOneAndUpdate(
{ _id: dashboardId, team: teamId },
{ $set: setPayload },
{ new: true },
);
if (!updatedDashboard) {
return {
isError: true,
content: [{ type: 'text' as const, text: 'Dashboard not found' }],
};
}
await cleanupDashboardAlerts({
dashboardId,
teamId,
internalTiles,
existingTileIds,
});
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
...convertToExternalDashboard(updatedDashboard),
...(frontendUrl
? { url: `${frontendUrl}/dashboards/${updatedDashboard._id}` }
: {}),
hint: 'Use hyperdx_query to test individual tile queries before viewing the dashboard.',
},
null,
2,
),
},
],
};
}

View file

@ -0,0 +1,320 @@
import {
AggregateFunctionSchema,
SearchConditionLanguageSchema,
} from '@hyperdx/common-utils/dist/types';
import { z } from 'zod';
import { externalQuantileLevelSchema } from '@/utils/zod';
// ─── Shared tile schemas for MCP dashboard tools ─────────────────────────────
const mcpTileSelectItemSchema = z
.object({
aggFn: AggregateFunctionSchema.describe(
'Aggregation function. "count" requires no valueExpression; all others do.',
),
valueExpression: z
.string()
.optional()
.describe(
'Column or expression to aggregate. Required for all aggFn except "count". ' +
'Use PascalCase for top-level columns (e.g. "Duration", "StatusCode"). ' +
"For span attributes use: SpanAttributes['key'] (e.g. SpanAttributes['http.method']). " +
"For resource attributes use: ResourceAttributes['key'] (e.g. ResourceAttributes['service.name']).",
),
where: z
.string()
.optional()
.default('')
.describe('Filter in Lucene syntax. Example: "level:error"'),
whereLanguage: SearchConditionLanguageSchema.optional().default('lucene'),
alias: z.string().optional().describe('Display label for this series'),
level: externalQuantileLevelSchema
.optional()
.describe('Percentile level for aggFn="quantile"'),
})
.superRefine((data, ctx) => {
if (data.level && data.aggFn !== 'quantile') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Level can only be used with quantile aggregation function',
});
}
if (data.valueExpression && data.aggFn === 'count') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Value expression cannot be used with count aggregation function',
});
} else if (!data.valueExpression && data.aggFn !== 'count') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'Value expression is required for non-count aggregation functions',
});
}
});
const mcpTileLayoutSchema = z.object({
name: z.string().describe('Tile title shown on the dashboard'),
x: z
.number()
.min(0)
.max(23)
.optional()
.default(0)
.describe('Horizontal grid position (023). Default 0'),
y: z
.number()
.min(0)
.optional()
.default(0)
.describe('Vertical grid position. Default 0'),
w: z
.number()
.min(1)
.max(24)
.optional()
.default(12)
.describe('Width in grid columns (124). Default 12'),
h: z
.number()
.min(1)
.optional()
.default(4)
.describe('Height in grid rows. Default 4'),
id: z
.string()
.max(36)
.optional()
.describe('Tile ID (auto-generated if omitted)'),
});
const mcpLineTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
displayType: z.literal('line').describe('Line chart over time'),
sourceId: z.string().describe('Source ID call hyperdx_list_sources'),
select: z
.array(mcpTileSelectItemSchema)
.min(1)
.max(20)
.describe('Metrics to plot (one series per item)'),
groupBy: z
.string()
.optional()
.describe(
'Column to split/group by. ' +
'Top-level columns use PascalCase (e.g. "SpanName", "StatusCode"). ' +
"Span attributes: SpanAttributes['key'] (e.g. SpanAttributes['http.method']). " +
"Resource attributes: ResourceAttributes['key'] (e.g. ResourceAttributes['service.name']).",
),
fillNulls: z.boolean().optional().default(true),
alignDateRangeToGranularity: z.boolean().optional(),
asRatio: z
.boolean()
.optional()
.describe(
'Plot as ratio of two metrics (requires exactly 2 select items)',
),
}),
});
const mcpBarTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
displayType: z
.literal('stacked_bar')
.describe('Stacked bar chart over time'),
sourceId: z.string().describe('Source ID call hyperdx_list_sources'),
select: z.array(mcpTileSelectItemSchema).min(1).max(20),
groupBy: z.string().optional(),
fillNulls: z.boolean().optional().default(true),
alignDateRangeToGranularity: z.boolean().optional(),
asRatio: z.boolean().optional(),
}),
});
const mcpTableTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
displayType: z.literal('table').describe('Tabular aggregated data'),
sourceId: z.string().describe('Source ID call hyperdx_list_sources'),
select: z.array(mcpTileSelectItemSchema).min(1).max(20),
groupBy: z
.string()
.optional()
.describe(
'Group rows by this column. Use PascalCase for top-level columns (e.g. "SpanName"). ' +
"For attributes: SpanAttributes['key'] or ResourceAttributes['key'].",
),
orderBy: z.string().optional().describe('Sort results by this column'),
asRatio: z.boolean().optional(),
}),
});
const mcpNumberFormatSchema = z
.object({
output: z
.enum(['currency', 'percent', 'byte', 'time', 'number'])
.describe(
'Format category. "time" auto-formats durations (use factor for input unit). ' +
'"byte" formats as KB/MB/GB. "currency" prepends a symbol. "percent" appends %.',
),
mantissa: z
.number()
.int()
.optional()
.describe('Decimal places (010). Not used for "time" output.'),
thousandSeparated: z
.boolean()
.optional()
.describe('Separate thousands (e.g. 1,234,567)'),
average: z
.boolean()
.optional()
.describe('Abbreviate large numbers (e.g. 1.2m)'),
decimalBytes: z
.boolean()
.optional()
.describe(
'Use decimal base for bytes (1KB = 1000). Only for "byte" output.',
),
factor: z
.number()
.optional()
.describe(
'Input unit factor for "time" output. ' +
'1 = seconds, 0.001 = milliseconds, 0.000001 = microseconds, 0.000000001 = nanoseconds.',
),
currencySymbol: z
.string()
.optional()
.describe('Currency symbol (e.g. "$"). Only for "currency" output.'),
unit: z
.string()
.optional()
.describe('Suffix appended to the value (e.g. " req/s")'),
})
.describe(
'Controls how the number value is formatted for display. ' +
'Most useful: { output: "time", factor: 0.000000001 } to auto-format nanosecond durations, ' +
'or { output: "number", mantissa: 2, thousandSeparated: true } for clean counts.',
);
const mcpNumberTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
displayType: z.literal('number').describe('Single aggregate scalar value'),
sourceId: z.string().describe('Source ID call hyperdx_list_sources'),
select: z
.array(mcpTileSelectItemSchema)
.length(1)
.describe('Exactly one metric to display'),
numberFormat: mcpNumberFormatSchema
.optional()
.describe(
'Display formatting for the number value. Example: { output: "time", factor: 0.000000001 } ' +
'to auto-format nanosecond durations as human-readable time.',
),
}),
});
const mcpPieTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
displayType: z.literal('pie').describe('Pie chart'),
sourceId: z.string().describe('Source ID call hyperdx_list_sources'),
select: z.array(mcpTileSelectItemSchema).length(1),
groupBy: z
.string()
.optional()
.describe(
'Column that defines pie slices. Use PascalCase for top-level columns. ' +
"For attributes: SpanAttributes['key'] or ResourceAttributes['key'].",
),
}),
});
const mcpSearchTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
displayType: z.literal('search').describe('Log/event search results list'),
sourceId: z.string().describe('Source ID call hyperdx_list_sources'),
where: z
.string()
.optional()
.default('')
.describe('Filter in Lucene syntax. Example: "level:error"'),
whereLanguage: SearchConditionLanguageSchema.optional().default('lucene'),
select: z
.string()
.optional()
.default('')
.describe(
'Columns to display (empty = defaults). Example: "body,service.name,duration"',
),
}),
});
const mcpMarkdownTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
displayType: z.literal('markdown').describe('Free-form Markdown text tile'),
markdown: z.string().optional().default(''),
}),
});
const mcpSqlTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
configType: z
.literal('sql')
.describe(
'Must be "sql" for raw SQL tiles. ' +
'ADVANCED: Only use raw SQL tiles when the builder tile types cannot express the query you need.',
),
displayType: z
.enum(['line', 'stacked_bar', 'table', 'number', 'pie'])
.describe('How to render the SQL results'),
connectionId: z
.string()
.describe(
'Connection ID (not sourceId) call hyperdx_list_sources to find available connections',
),
sqlTemplate: z
.string()
.describe(
'Raw ClickHouse SQL query. Always include a LIMIT clause to avoid excessive data.\n' +
'Use query parameters: {startDateMilliseconds:Int64}, {endDateMilliseconds:Int64}, ' +
'{intervalSeconds:Int64}, {intervalMilliseconds:Int64}.\n' +
'Or use macros: $__timeFilter(col), $__timeFilter_ms(col), $__dateFilter(col), ' +
'$__fromTime, $__toTime, $__fromTime_ms, $__toTime_ms, ' +
'$__timeInterval(col), $__timeInterval_ms(col), $__interval_s, $__filters.\n' +
'Example: "SELECT $__timeInterval(TimestampTime) AS ts, ServiceName, count() ' +
'FROM otel_logs WHERE $__timeFilter(TimestampTime) AND $__filters ' +
'GROUP BY ServiceName, ts ORDER BY ts"',
),
fillNulls: z.boolean().optional(),
alignDateRangeToGranularity: z.boolean().optional(),
}),
});
const mcpTileSchema = z.union([
mcpLineTileSchema,
mcpBarTileSchema,
mcpTableTileSchema,
mcpNumberTileSchema,
mcpPieTileSchema,
mcpSearchTileSchema,
mcpMarkdownTileSchema,
mcpSqlTileSchema,
]);
export const mcpTilesParam = z
.array(mcpTileSchema)
.describe(
'Array of dashboard tiles. Each tile needs a name, optional layout (x/y/w/h), and a config block. ' +
'The config block varies by displayType use hyperdx_list_sources for sourceId and connectionId values.\n\n' +
'Example tiles:\n' +
'1. Line chart: { "name": "Error Rate", "config": { "displayType": "line", "sourceId": "<from list_sources>", ' +
'"groupBy": "ResourceAttributes[\'service.name\']", "select": [{ "aggFn": "count", "where": "StatusCode:STATUS_CODE_ERROR" }] } }\n' +
'2. Table: { "name": "Top Endpoints", "config": { "displayType": "table", "sourceId": "<from list_sources>", ' +
'"groupBy": "SpanAttributes[\'http.route\']", "select": [{ "aggFn": "count" }, { "aggFn": "avg", "valueExpression": "Duration" }] } }\n' +
'3. Number: { "name": "Total Requests", "config": { "displayType": "number", "sourceId": "<from list_sources>", ' +
'"select": [{ "aggFn": "count" }], "numberFormat": { "output": "number", "average": true } } }\n' +
'4. Number (duration): { "name": "P95 Latency", "config": { "displayType": "number", "sourceId": "<from list_sources>", ' +
'"select": [{ "aggFn": "quantile", "level": 0.95, "valueExpression": "Duration" }], ' +
'"numberFormat": { "output": "time", "factor": 0.000000001 } } }',
);

View file

@ -0,0 +1,264 @@
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata';
import { getFirstTimestampValueExpression } from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import type {
ChartConfigWithDateRange,
MetricTable,
} from '@hyperdx/common-utils/dist/types';
import { DisplayType, SourceKind } from '@hyperdx/common-utils/dist/types';
import ms from 'ms';
import { getConnectionById } from '@/controllers/connection';
import { getSource } from '@/controllers/sources';
import {
convertToInternalTileConfig,
isConfigTile,
} from '@/routers/external-api/v2/utils/dashboards';
import { trimToolResponse } from '@/utils/trimToolResponse';
import type { ExternalDashboardTileWithId } from '@/utils/zod';
// ─── Time range ──────────────────────────────────────────────────────────────
export function parseTimeRange(
startTime?: string,
endTime?: string,
): { error: string } | { startDate: Date; endDate: Date } {
const endDate = endTime ? new Date(endTime) : new Date();
const startDate = startTime
? new Date(startTime)
: new Date(endDate.getTime() - ms('15m'));
if (isNaN(endDate.getTime()) || isNaN(startDate.getTime())) {
return {
error: 'Invalid startTime or endTime: must be valid ISO 8601 strings',
};
}
return { startDate, endDate };
}
// ─── Result helpers ──────────────────────────────────────────────────────────
function isEmptyResult(result: unknown): boolean {
if (result == null) return true;
if (Array.isArray(result)) return result.length === 0;
if (typeof result === 'object' && result !== null) {
const obj = result as Record<string, unknown>;
if (Array.isArray(obj.data) && obj.data.length === 0) return true;
if (obj.rows != null && Number(obj.rows) === 0) return true;
}
return false;
}
function formatQueryResult(result: unknown) {
const trimmedResult = trimToolResponse(result);
const isTrimmed =
JSON.stringify(trimmedResult).length < JSON.stringify(result).length;
const empty = isEmptyResult(result);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
result: trimmedResult,
...(isTrimmed
? {
note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.',
}
: {}),
...(empty
? {
hint: 'No data found in the queried time range. Try setting startTime to a wider window (e.g. 24 hours ago) or check that filters match existing data.',
}
: {}),
},
null,
2,
),
},
],
};
}
// ─── Tile execution ──────────────────────────────────────────────────────────
export async function runConfigTile(
teamId: string,
tile: ExternalDashboardTileWithId,
startDate: Date,
endDate: Date,
options?: { maxResults?: number },
) {
if (!isConfigTile(tile)) {
return {
isError: true as const,
content: [
{ type: 'text' as const, text: 'Invalid tile: config field missing' },
],
};
}
const internalTile = convertToInternalTileConfig(tile);
const savedConfig = internalTile.config;
if (!isRawSqlSavedChartConfig(savedConfig)) {
const builderConfig = savedConfig;
if (
!builderConfig.source ||
builderConfig.displayType === DisplayType.Markdown
) {
return {
content: [
{
type: 'text' as const,
text: 'Markdown tile: no query to execute.',
},
],
};
}
const source = await getSource(teamId, builderConfig.source);
if (!source) {
return {
isError: true as const,
content: [
{
type: 'text' as const,
text: `Source not found: ${builderConfig.source}`,
},
],
};
}
const connection = await getConnectionById(
teamId,
source.connection.toString(),
true,
);
if (!connection) {
return {
isError: true as const,
content: [
{
type: 'text' as const,
text: `Connection not found for source: ${builderConfig.source}`,
},
],
};
}
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});
const isSearch = builderConfig.displayType === DisplayType.Search;
const defaultTableSelect =
'defaultTableSelectExpression' in source
? source.defaultTableSelectExpression
: undefined;
const implicitColumn =
'implicitColumnExpression' in source
? source.implicitColumnExpression
: undefined;
const searchOverrides = isSearch
? {
select: builderConfig.select || defaultTableSelect || '*',
groupBy: undefined,
granularity: undefined,
orderBy: [
{
ordering: 'DESC' as const,
valueExpression: getFirstTimestampValueExpression(
source.timestampValueExpression,
),
},
],
limit: { limit: options?.maxResults ?? 50, offset: 0 },
}
: {};
const chartConfig = {
...builderConfig,
...searchOverrides,
from: {
databaseName: source.from.databaseName,
tableName: source.from.tableName,
},
connection: source.connection.toString(),
timestampValueExpression: source.timestampValueExpression,
implicitColumnExpression: implicitColumn,
dateRange: [startDate, endDate] as [Date, Date],
} satisfies ChartConfigWithDateRange;
const metadata = getMetadata(clickhouseClient);
const result = await clickhouseClient.queryChartConfig({
config: chartConfig,
metadata,
querySettings: source.querySettings,
});
return formatQueryResult(result);
}
// Raw SQL tile — hydrate source fields for macro support ($__sourceTable, $__filters)
let sourceFields: {
from?: { databaseName: string; tableName: string };
implicitColumnExpression?: string;
metricTables?: MetricTable;
} = {};
if (savedConfig.source) {
const source = await getSource(teamId, savedConfig.source);
if (source) {
sourceFields = {
from: source.from,
implicitColumnExpression:
'implicitColumnExpression' in source
? source.implicitColumnExpression
: undefined,
metricTables:
source.kind === SourceKind.Metric ? source.metricTables : undefined,
};
}
}
const connection = await getConnectionById(
teamId,
savedConfig.connection,
true,
);
if (!connection) {
return {
isError: true as const,
content: [
{
type: 'text' as const,
text: `Connection not found: ${savedConfig.connection}`,
},
],
};
}
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});
const chartConfig = {
...savedConfig,
...sourceFields,
dateRange: [startDate, endDate] as [Date, Date],
} satisfies ChartConfigWithDateRange;
const metadata = getMetadata(clickhouseClient);
const result = await clickhouseClient.queryChartConfig({
config: chartConfig,
metadata,
querySettings: undefined,
});
return formatQueryResult(result);
}

View file

@ -0,0 +1,117 @@
import { ObjectId } from 'mongodb';
import type { ExternalDashboardTileWithId } from '@/utils/zod';
import { externalDashboardTileSchemaWithId } from '@/utils/zod';
import { withToolTracing } from '../../utils/tracing';
import type { ToolDefinition } from '../types';
import { parseTimeRange, runConfigTile } from './helpers';
import { hyperdxQuerySchema } from './schemas';
// ─── Tool definition ─────────────────────────────────────────────────────────
const queryTools: ToolDefinition = (server, context) => {
const { teamId } = context;
server.registerTool(
'hyperdx_query',
{
title: 'Query Data',
description:
'Query observability data (logs, metrics, traces) from HyperDX. ' +
'Use hyperdx_list_sources first to find sourceId/connectionId values. ' +
'Set displayType to control the query shape.\n\n' +
'PREFERRED: Use the builder display types (line, stacked_bar, table, number, pie) ' +
'for aggregated metrics, or "search" for browsing individual log/event rows. ' +
'These are safer, easier to construct, and cover most use cases.\n\n' +
'ADVANCED: Use displayType "sql" only when you need capabilities the builder cannot express, ' +
'such as JOINs, sub-queries, CTEs, or querying tables not registered as sources. ' +
'Raw SQL requires a connectionId (not sourceId) and a hand-written ClickHouse SQL query.\n\n' +
'Column naming: Top-level columns are PascalCase (Duration, StatusCode, SpanName). ' +
"Map attributes use bracket syntax: SpanAttributes['http.method'], ResourceAttributes['service.name']. " +
'Call hyperdx_list_sources to discover available columns and attribute keys for each source.',
inputSchema: hyperdxQuerySchema,
},
withToolTracing('hyperdx_query', context, async input => {
const timeRange = parseTimeRange(input.startTime, input.endTime);
if ('error' in timeRange) {
return {
isError: true,
content: [{ type: 'text' as const, text: timeRange.error }],
};
}
const { startDate, endDate } = timeRange;
let tile: ExternalDashboardTileWithId;
if (input.displayType === 'sql') {
tile = externalDashboardTileSchemaWithId.parse({
id: new ObjectId().toString(),
name: 'MCP SQL',
x: 0,
y: 0,
w: 24,
h: 6,
config: {
configType: 'sql' as const,
displayType: 'table' as const,
connectionId: input.connectionId,
sqlTemplate: input.sql,
},
});
} else if (input.displayType === 'search') {
tile = externalDashboardTileSchemaWithId.parse({
id: new ObjectId().toString(),
name: 'MCP Search',
x: 0,
y: 0,
w: 24,
h: 6,
config: {
displayType: 'search' as const,
sourceId: input.sourceId,
select: input.columns ?? '',
where: input.where ?? '',
whereLanguage: input.whereLanguage ?? 'lucene',
},
});
} else {
tile = externalDashboardTileSchemaWithId.parse({
id: new ObjectId().toString(),
name: 'MCP Query',
x: 0,
y: 0,
w: 12,
h: 4,
config: {
displayType: input.displayType,
sourceId: input.sourceId,
select: input.select.map(s => ({
aggFn: s.aggFn,
where: s.where ?? '',
whereLanguage: s.whereLanguage ?? 'lucene',
valueExpression: s.valueExpression,
alias: s.alias,
level: s.level,
})),
groupBy: input.groupBy ?? undefined,
orderBy: input.orderBy ?? undefined,
...(input.granularity ? { granularity: input.granularity } : {}),
},
});
}
return runConfigTile(
teamId.toString(),
tile,
startDate,
endDate,
input.displayType === 'search'
? { maxResults: input.maxResults }
: undefined,
);
}),
);
};
export default queryTools;

View file

@ -0,0 +1,220 @@
import { z } from 'zod';
// ─── Shared schemas ──────────────────────────────────────────────────────────
const mcpAggFnSchema = z
.enum([
'avg',
'count',
'count_distinct',
'last_value',
'max',
'min',
'quantile',
'sum',
'none',
])
.describe(
'Aggregation function:\n' +
' count count matching rows (no valueExpression needed)\n' +
' sum / avg / min / max aggregate a numeric column (valueExpression required)\n' +
' count_distinct unique value count (valueExpression required)\n' +
' quantile percentile; also set level (valueExpression required)\n' +
' last_value most recent value of a column\n' +
' none pass a raw expression through unchanged',
);
const mcpSelectItemSchema = z.object({
aggFn: mcpAggFnSchema,
valueExpression: z
.string()
.optional()
.describe(
'Column or expression to aggregate. Required for every aggFn except "count". ' +
'Use PascalCase for top-level columns (e.g. "Duration", "StatusCode"). ' +
"For span attributes use: SpanAttributes['key'] (e.g. SpanAttributes['http.method']). " +
"For resource attributes use: ResourceAttributes['key'] (e.g. ResourceAttributes['service.name']).",
),
where: z
.string()
.optional()
.default('')
.describe(
'Row filter in Lucene syntax. ' +
'Examples: "level:error", "service.name:api AND http.status_code:>=500"',
),
whereLanguage: z
.enum(['lucene', 'sql'])
.optional()
.default('lucene')
.describe('Query language for the where filter. Default: lucene'),
alias: z
.string()
.optional()
.describe('Display label for this series. Example: "Error rate"'),
level: z
.union([z.literal(0.5), z.literal(0.9), z.literal(0.95), z.literal(0.99)])
.optional()
.describe(
'Percentile level. Only applicable when aggFn is "quantile". ' +
'Allowed values: 0.5, 0.9, 0.95, 0.99',
),
});
const mcpTimeRangeSchema = z.object({
startTime: z
.string()
.optional()
.describe(
'Start of the query window as ISO 8601. Default: 15 minutes ago. ' +
'If results are empty, try a wider range (e.g. 24 hours).',
),
endTime: z
.string()
.optional()
.describe('End of the query window as ISO 8601. Default: now.'),
});
// ─── Discriminated union schema for hyperdx_query ───────────────────────────
const builderQuerySchema = mcpTimeRangeSchema.extend({
displayType: z
.enum(['line', 'stacked_bar', 'table', 'number', 'pie'])
.describe(
'How to visualize the query results:\n' +
' line time-series line chart\n' +
' stacked_bar time-series stacked bar chart\n' +
' table grouped aggregation as rows\n' +
' number single aggregate scalar\n' +
' pie pie chart (one metric, grouped)',
),
sourceId: z
.string()
.describe(
'Source ID. Call hyperdx_list_sources to find available sources.',
),
select: z
.array(mcpSelectItemSchema)
.min(1)
.max(10)
.describe(
'Metrics to compute. Each item defines an aggregation. ' +
'For "number" display, provide exactly 1 item. ' +
'Example: [{ aggFn: "count" }, { aggFn: "avg", valueExpression: "Duration" }]',
),
groupBy: z
.string()
.optional()
.describe(
'Column to group/split by. ' +
'Top-level columns use PascalCase (e.g. "SpanName", "StatusCode"). ' +
"Span attributes: SpanAttributes['key'] (e.g. SpanAttributes['http.method']). " +
"Resource attributes: ResourceAttributes['key'] (e.g. ResourceAttributes['service.name']).",
),
orderBy: z
.string()
.optional()
.describe('Column to sort results by (table display only).'),
granularity: z
.string()
.optional()
.describe(
'Time bucket size for time-series charts (line, stacked_bar). ' +
'Format: "<number> <unit>" where unit is second, minute, hour, or day. ' +
'Examples: "1 minute", "5 minute", "1 hour", "1 day". ' +
'Omit to let HyperDX pick automatically based on the time range.',
),
});
const searchQuerySchema = mcpTimeRangeSchema.extend({
displayType: z
.literal('search')
.describe('Search and filter individual log/event rows'),
sourceId: z
.string()
.describe(
'Source ID. Call hyperdx_list_sources to find available sources.',
),
where: z
.string()
.optional()
.default('')
.describe(
'Row filter. Examples: "level:error", "service.name:api AND duration:>500"',
),
whereLanguage: z
.enum(['lucene', 'sql'])
.optional()
.default('lucene')
.describe('Query language for the where filter. Default: lucene'),
columns: z
.string()
.optional()
.default('')
.describe(
'Comma-separated columns to include. Leave empty for defaults. ' +
'Example: "body,service.name,duration"',
),
maxResults: z
.number()
.min(1)
.max(200)
.optional()
.default(50)
.describe(
'Maximum number of rows to return (1200). Default: 50. ' +
'Use smaller values to reduce response size.',
),
});
const sqlQuerySchema = mcpTimeRangeSchema.extend({
displayType: z
.literal('sql')
.describe(
'ADVANCED: Execute raw SQL directly against ClickHouse. ' +
'Only use this when the builder query types (line, stacked_bar, table, number, pie, search) ' +
'cannot express the query you need — e.g. complex JOINs, sub-queries, CTEs, or ' +
'querying tables not registered as sources. ' +
'Prefer the builder display types for standard queries as they are safer and easier to use.',
),
connectionId: z
.string()
.describe(
'Connection ID (not sourceId). Call hyperdx_list_sources to find available connections.',
),
sql: z
.string()
.describe(
'Raw ClickHouse SQL query to execute. ' +
'Always include a LIMIT clause to avoid returning excessive data.\n\n' +
'QUERY PARAMETERS (ClickHouse native parameterized syntax):\n' +
' {startDateMilliseconds:Int64} — start of date range in ms since epoch\n' +
' {endDateMilliseconds:Int64} — end of date range in ms since epoch\n' +
' {intervalSeconds:Int64} — time bucket size in seconds (time-series only)\n' +
' {intervalMilliseconds:Int64} — time bucket size in milliseconds (time-series only)\n\n' +
'MACROS (expanded before execution):\n' +
' $__timeFilter(column) — expands to: column >= <start> AND column <= <end> (DateTime precision)\n' +
' $__timeFilter_ms(column) — same but with DateTime64 millisecond precision\n' +
' $__dateFilter(column) — same but with Date precision\n' +
' $__dateTimeFilter(dateCol, timeCol) — filters on both a Date and DateTime column\n' +
' $__dt(dateCol, timeCol) — alias for $__dateTimeFilter\n' +
' $__fromTime / $__toTime — start/end as DateTime values\n' +
' $__fromTime_ms / $__toTime_ms — start/end as DateTime64 values\n' +
' $__timeInterval(column) — time bucket expression: toStartOfInterval(toDateTime(column), INTERVAL ...)\n' +
' $__timeInterval_ms(column) — same with millisecond precision\n' +
' $__interval_s — raw interval in seconds\n' +
' $__filters — placeholder for dashboard filter conditions (resolves to 1=1 when no filters)\n\n' +
'Example (time-series): "SELECT $__timeInterval(TimestampTime) AS ts, ServiceName, count() ' +
'FROM otel_logs WHERE $__timeFilter(TimestampTime) GROUP BY ServiceName, ts ORDER BY ts"\n\n' +
'Example (table): "SELECT ServiceName, count() AS n FROM otel_logs ' +
'WHERE TimestampTime >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) ' +
'AND TimestampTime < fromUnixTimestamp64Milli({endDateMilliseconds:Int64}) ' +
'GROUP BY ServiceName ORDER BY n DESC LIMIT 20"',
),
});
export const hyperdxQuerySchema = z.discriminatedUnion('displayType', [
builderQuerySchema,
searchQuerySchema,
sqlQuerySchema,
]);

View file

@ -0,0 +1,10 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
export type McpContext = {
teamId: string;
userId?: string;
};
export type ToolDefinition = (server: McpServer, context: McpContext) => void;
export type PromptDefinition = (server: McpServer, context: McpContext) => void;

View file

@ -0,0 +1,83 @@
import opentelemetry, { SpanStatusCode } from '@opentelemetry/api';
import { CODE_VERSION } from '@/config';
import logger from '@/utils/logger';
import type { McpContext } from '../tools/types';
const mcpTracer = opentelemetry.trace.getTracer('hyperdx-mcp', CODE_VERSION);
type ToolResult = {
content: { type: 'text'; text: string }[];
isError?: boolean;
};
/**
* Wraps an MCP tool handler with tracing and structured logging.
* Creates a span for each tool invocation and logs start/end with duration.
*/
export function withToolTracing<TArgs>(
toolName: string,
context: McpContext,
handler: (args: TArgs) => Promise<ToolResult>,
): (args: TArgs) => Promise<ToolResult> {
return async (args: TArgs) => {
return mcpTracer.startActiveSpan(`mcp.tool.${toolName}`, async span => {
const startTime = Date.now();
const logContext = {
tool: toolName,
teamId: context.teamId,
userId: context.userId,
};
span.setAttribute('mcp.tool.name', toolName);
span.setAttribute('mcp.team.id', context.teamId);
if (context.userId) {
span.setAttribute('mcp.user.id', context.userId);
}
logger.info(logContext, `MCP tool invoked: ${toolName}`);
try {
const result = await handler(args);
const durationMs = Date.now() - startTime;
if (result.isError) {
span.setStatus({ code: SpanStatusCode.ERROR });
span.setAttribute('mcp.tool.error', true);
logger.warn(
{ ...logContext, durationMs },
`MCP tool error: ${toolName}`,
);
} else {
span.setStatus({ code: SpanStatusCode.OK });
logger.info(
{ ...logContext, durationMs },
`MCP tool completed: ${toolName}`,
);
}
span.setAttribute('mcp.tool.duration_ms', durationMs);
span.end();
return result;
} catch (err) {
const durationMs = Date.now() - startTime;
span.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : String(err),
});
span.recordException(
err instanceof Error ? err : new Error(String(err)),
);
span.setAttribute('mcp.tool.duration_ms', durationMs);
span.end();
logger.error(
{ ...logContext, durationMs, error: err },
`MCP tool failed: ${toolName}`,
);
throw err;
}
});
};
}

View file

@ -3,6 +3,7 @@ import type { NextFunction, Request, Response } from 'express';
import { IS_PROD } from '@/config';
import { BaseError, isOperationalError, StatusCode } from '@/utils/errors';
import logger from '@/utils/logger';
// WARNING: need to keep the 4th arg for express to identify it as an error-handling middleware function
export const appErrorHandler = (
@ -11,7 +12,11 @@ export const appErrorHandler = (
res: Response,
next: NextFunction,
) => {
console.error(err);
if (isOperationalError(err)) {
logger.warn({ err }, err.message);
} else {
logger.error({ err }, err.message);
}
const userFacingErrorMessage = isOperationalError(err)
? err.name || err.message

View file

@ -1,14 +1,13 @@
import { ALERT_INTERVAL_TO_MINUTES } from '@hyperdx/common-utils/dist/types';
import {
ALERT_INTERVAL_TO_MINUTES,
AlertThresholdType,
} from '@hyperdx/common-utils/dist/types';
export { AlertThresholdType } from '@hyperdx/common-utils/dist/types';
import mongoose, { Schema } from 'mongoose';
import type { ObjectId } from '.';
import Team from './team';
export enum AlertThresholdType {
ABOVE = 'above',
BELOW = 'below',
}
export enum AlertState {
ALERT = 'ALERT',
DISABLED = 'DISABLED',
@ -51,6 +50,8 @@ export interface IAlert {
state: AlertState;
team: ObjectId;
threshold: number;
/** The upper bound for BETWEEN and NOT BETWEEN threshold types */
thresholdMax?: number;
thresholdType: AlertThresholdType;
createdBy?: ObjectId;
@ -84,6 +85,10 @@ const AlertSchema = new Schema<IAlert>(
type: Number,
required: true,
},
thresholdMax: {
type: Number,
required: false,
},
thresholdType: {
type: String,
enum: AlertThresholdType,

View file

@ -7,6 +7,8 @@ import type { ObjectId } from '.';
export interface IDashboard extends z.infer<typeof DashboardSchema> {
_id: ObjectId;
team: ObjectId;
createdBy?: ObjectId;
updatedBy?: ObjectId;
createdAt: Date;
updatedAt: Date;
}
@ -32,6 +34,16 @@ export default mongoose.model<IDashboard>(
savedQueryLanguage: { type: String, required: false },
savedFilterValues: { type: mongoose.Schema.Types.Array, required: false },
containers: { type: mongoose.Schema.Types.Array, required: false },
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: false,
},
updatedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: false,
},
},
{
timestamps: true,

View file

@ -0,0 +1,49 @@
import type { PinnedFiltersValue } from '@hyperdx/common-utils/dist/types';
import mongoose, { Schema } from 'mongoose';
import type { ObjectId } from '.';
interface IPinnedFilter {
_id: ObjectId;
team: ObjectId;
source: ObjectId;
fields: string[];
filters: PinnedFiltersValue;
createdAt: Date;
updatedAt: Date;
}
const PinnedFilterSchema = new Schema<IPinnedFilter>(
{
team: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Team',
},
source: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Source',
},
fields: {
type: [String],
default: [],
},
filters: {
type: Schema.Types.Mixed,
default: {},
},
},
{
timestamps: true,
toJSON: { getters: true },
},
);
// One document per team+source combination
PinnedFilterSchema.index({ team: 1, source: 1 }, { unique: true });
export default mongoose.model<IPinnedFilter>(
'PinnedFilter',
PinnedFilterSchema,
);

View file

@ -9,6 +9,8 @@ export interface ISavedSearch
_id: ObjectId;
team: ObjectId;
source: ObjectId;
createdBy?: ObjectId;
updatedBy?: ObjectId;
createdAt: Date;
updatedAt: Date;
}
@ -35,6 +37,16 @@ export const SavedSearch = mongoose.model<ISavedSearch>(
},
tags: [String],
filters: [{ type: mongoose.Schema.Types.Mixed }],
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: false,
},
updatedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: false,
},
},
{
toJSON: { virtuals: true },

View file

@ -82,6 +82,7 @@ type CollectorConfig = {
logs_table_name: string;
timeout: string;
create_schema: string;
json: string;
retry_on_failure: {
enabled: boolean;
initial_interval: string;
@ -97,6 +98,7 @@ type CollectorConfig = {
ttl: string;
timeout: string;
create_schema: string;
json: string;
retry_on_failure: {
enabled: boolean;
initial_interval: string;
@ -205,6 +207,7 @@ export const buildOtelCollectorConfig = (
timeout: '5s',
create_schema:
'${env:HYPERDX_OTEL_EXPORTER_CREATE_LEGACY_SCHEMA:-false}',
json: '${env:HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE:-false}',
retry_on_failure: {
enabled: true,
initial_interval: '5s',
@ -221,6 +224,7 @@ export const buildOtelCollectorConfig = (
timeout: '5s',
create_schema:
'${env:HYPERDX_OTEL_EXPORTER_CREATE_LEGACY_SCHEMA:-false}',
json: '${env:HYPERDX_OTEL_EXPORTER_CLICKHOUSE_JSON_ENABLE:-false}',
retry_on_failure: {
enabled: true,
initial_interval: '5s',

View file

@ -1,12 +1,21 @@
import {
AlertThresholdType,
DisplayType,
} from '@hyperdx/common-utils/dist/types';
import {
getLoggedInAgent,
getServer,
makeAlertInput,
makeRawSqlAlertTile,
makeRawSqlNumberAlertTile,
makeRawSqlTile,
makeTile,
randomMongoId,
RAW_SQL_ALERT_TEMPLATE,
} from '@/fixtures';
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
import Alert, { AlertSource, AlertState } from '@/models/alert';
import AlertHistory from '@/models/alertHistory';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
@ -550,8 +559,61 @@ describe('alerts router', () => {
await agent.delete(`/alerts/${fakeId}/silenced`).expect(404); // Should fail
});
it('rejects creating an alert on a raw SQL tile', async () => {
const rawSqlTile = makeRawSqlTile();
it('allows creating an alert on a raw SQL line tile', async () => {
const rawSqlTile = makeRawSqlAlertTile();
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [rawSqlTile],
tags: [],
})
.expect(200);
const alert = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: rawSqlTile.id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
expect(alert.body.data.dashboard).toBe(dashboard.body.id);
expect(alert.body.data.tileId).toBe(rawSqlTile.id);
});
it('allows creating an alert on a raw SQL number tile', async () => {
const rawSqlTile = makeRawSqlNumberAlertTile();
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [rawSqlTile],
tags: [],
})
.expect(200);
const alert = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: rawSqlTile.id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
expect(alert.body.data.dashboard).toBe(dashboard.body.id);
expect(alert.body.data.tileId).toBe(rawSqlTile.id);
});
it('rejects creating an alert on a raw SQL table tile', async () => {
const rawSqlTile = makeRawSqlTile({
displayType: DisplayType.Table,
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
});
const dashboard = await agent
.post('/dashboards')
.send({
@ -573,9 +635,72 @@ describe('alerts router', () => {
.expect(400);
});
it('rejects updating an alert to reference a raw SQL tile', async () => {
it('rejects creating an alert on a raw SQL tile without interval params', async () => {
const rawSqlTile = makeRawSqlTile({
sqlTemplate: 'SELECT count() FROM otel_logs',
});
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [rawSqlTile],
tags: [],
})
.expect(200);
await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: rawSqlTile.id,
webhookId: webhook._id.toString(),
}),
)
.expect(400);
});
it('allows updating an alert to reference a raw SQL number tile', async () => {
const regularTile = makeTile();
const rawSqlTile = makeRawSqlTile();
const rawSqlTile = makeRawSqlNumberAlertTile();
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [regularTile, rawSqlTile],
tags: [],
})
.expect(200);
const alert = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: regularTile.id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
await agent
.put(`/alerts/${alert.body.data._id}`)
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: rawSqlTile.id,
webhookId: webhook._id.toString(),
}),
})
.expect(200);
});
it('rejects updating an alert to reference a raw SQL table tile', async () => {
const regularTile = makeTile();
const rawSqlTile = makeRawSqlTile({
displayType: DisplayType.Table,
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
});
const dashboard = await agent
.post('/dashboards')
.send({
@ -607,4 +732,84 @@ describe('alerts router', () => {
})
.expect(400);
});
describe('GET /alerts/:id', () => {
it('returns 404 for non-existent alert', async () => {
const fakeId = randomMongoId();
await agent.get(`/alerts/${fakeId}`).expect(404);
});
it('returns alert with empty history when no history exists', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);
const alert = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
const res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200);
expect(res.body.data._id).toBe(alert.body.data._id);
expect(res.body.data.history).toEqual([]);
expect(res.body.data.threshold).toBe(alert.body.data.threshold);
expect(res.body.data.interval).toBe(alert.body.data.interval);
expect(res.body.data.dashboard).toBeDefined();
expect(res.body.data.tileId).toBe(dashboard.body.tiles[0].id);
});
it('returns alert with history entries', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);
const alert = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
const now = new Date(Date.now() - 60000);
const earlier = new Date(Date.now() - 120000);
await AlertHistory.create({
alert: alert.body.data._id,
createdAt: now,
state: AlertState.ALERT,
counts: 5,
lastValues: [{ startTime: now, count: 5 }],
});
await AlertHistory.create({
alert: alert.body.data._id,
createdAt: earlier,
state: AlertState.OK,
counts: 0,
lastValues: [{ startTime: earlier, count: 0 }],
});
const res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200);
expect(res.body.data._id).toBe(alert.body.data._id);
expect(res.body.data.history).toHaveLength(2);
expect(res.body.data.history[0].state).toBe('ALERT');
expect(res.body.data.history[0].counts).toBe(5);
expect(res.body.data.history[1].state).toBe('OK');
expect(res.body.data.history[1].counts).toBe(0);
});
});
});

View file

@ -20,6 +20,8 @@ import {
makeTile,
} from '../../../fixtures';
import Alert from '../../../models/alert';
import Dashboard from '../../../models/dashboard';
import User from '../../../models/user';
const MOCK_DASHBOARD = {
name: 'Test Dashboard',
@ -78,6 +80,45 @@ describe('dashboard router', () => {
);
});
it('sets createdBy and updatedBy on create and populates them in GET', async () => {
const created = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);
// GET all dashboards
const allDashboards = await agent.get('/dashboards').expect(200);
const dashboard = allDashboards.body.find(d => d._id === created.body.id);
expect(dashboard.createdBy).toMatchObject({ email: user.email });
expect(dashboard.updatedBy).toMatchObject({ email: user.email });
});
it('populates updatedBy with a different user after DB update', async () => {
const created = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);
// Create a second user on the same team
const secondUser = await User.create({
email: 'second@test.com',
name: 'Second User',
team: team._id,
});
// Simulate a different user updating the dashboard
await Dashboard.findByIdAndUpdate(created.body.id, {
updatedBy: secondUser._id,
});
const allDashboards = await agent.get('/dashboards').expect(200);
const dashboard = allDashboards.body.find(d => d._id === created.body.id);
expect(dashboard.createdBy).toMatchObject({ email: user.email });
expect(dashboard.updatedBy).toMatchObject({
email: 'second@test.com',
});
});
it('can update a dashboard', async () => {
const dashboard = await agent
.post('/dashboards')

View file

@ -0,0 +1,214 @@
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { Types } from 'mongoose';
import { getLoggedInAgent, getServer } from '@/fixtures';
import { Source } from '@/models/source';
const MOCK_SOURCE: Omit<Extract<TSource, { kind: 'log' }>, 'id'> = {
kind: SourceKind.Log,
name: 'Test Source',
connection: new Types.ObjectId().toString(),
from: { databaseName: 'test_db', tableName: 'test_table' },
timestampValueExpression: 'timestamp',
defaultTableSelectExpression: 'body',
};
describe('pinnedFilters router', () => {
const server = getServer();
let agent: Awaited<ReturnType<typeof getLoggedInAgent>>['agent'];
let team: Awaited<ReturnType<typeof getLoggedInAgent>>['team'];
let sourceId: string;
beforeAll(async () => {
await server.start();
});
beforeEach(async () => {
const result = await getLoggedInAgent(server);
agent = result.agent;
team = result.team;
// Create a real source owned by this team
const source = await Source.create({ ...MOCK_SOURCE, team: team._id });
sourceId = source._id.toString();
});
afterEach(async () => {
await server.clearDBs();
});
afterAll(async () => {
await server.stop();
});
describe('GET /pinned-filters', () => {
it('returns null when no pinned filters exist', async () => {
const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);
expect(res.body.team).toBeNull();
});
it('rejects invalid source id', async () => {
await agent.get('/pinned-filters?source=not-an-objectid').expect(400);
});
it('rejects missing source param', async () => {
await agent.get('/pinned-filters').expect(400);
});
it('returns 404 for a source not owned by the team', async () => {
const foreignSourceId = new Types.ObjectId().toString();
await agent.get(`/pinned-filters?source=${foreignSourceId}`).expect(404);
});
});
describe('PUT /pinned-filters', () => {
it('can create pinned filters', async () => {
const res = await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName', 'SeverityText'],
filters: { ServiceName: ['web', 'api'] },
})
.expect(200);
expect(res.body.fields).toEqual(['ServiceName', 'SeverityText']);
expect(res.body.filters).toEqual({ ServiceName: ['web', 'api'] });
expect(res.body.id).toBeDefined();
});
it('upserts on repeated PUT', async () => {
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);
const res = await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName', 'SeverityText'],
filters: { ServiceName: ['web', 'api'], SeverityText: ['error'] },
})
.expect(200);
expect(res.body.fields).toEqual(['ServiceName', 'SeverityText']);
expect(res.body.filters).toEqual({
ServiceName: ['web', 'api'],
SeverityText: ['error'],
});
});
it('rejects invalid source id', async () => {
await agent
.put('/pinned-filters')
.send({ source: 'not-valid', fields: [], filters: {} })
.expect(400);
});
it('returns 404 for a source not owned by the team', async () => {
const foreignSourceId = new Types.ObjectId().toString();
await agent
.put('/pinned-filters')
.send({ source: foreignSourceId, fields: [], filters: {} })
.expect(404);
});
});
describe('GET + PUT round-trip', () => {
it('returns data after PUT', async () => {
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);
const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);
expect(res.body.team).not.toBeNull();
expect(res.body.team.fields).toEqual(['ServiceName']);
expect(res.body.team.filters).toEqual({ ServiceName: ['web'] });
});
it('can reset by sending empty fields and filters', async () => {
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);
await agent
.put('/pinned-filters')
.send({ source: sourceId, fields: [], filters: {} })
.expect(200);
const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);
expect(res.body.team).not.toBeNull();
expect(res.body.team.fields).toEqual([]);
expect(res.body.team.filters).toEqual({});
});
});
describe('source scoping', () => {
it('pins are scoped to their source', async () => {
const source2 = await Source.create({ ...MOCK_SOURCE, team: team._id });
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['ServiceName'],
filters: { ServiceName: ['web'] },
})
.expect(200);
const res = await agent
.get(`/pinned-filters?source=${source2._id}`)
.expect(200);
expect(res.body.team).toBeNull();
});
});
// Note: cross-team isolation (Team B cannot read Team A's pins) is enforced
// by the MongoDB query filtering on teamId AND the source ownership check
// (getSource validates source.team === teamId). Multi-team integration tests
// are not possible in this single-team environment (register returns 409).
describe('filter values with booleans', () => {
it('supports boolean values in filters', async () => {
await agent
.put('/pinned-filters')
.send({
source: sourceId,
fields: ['isRootSpan'],
filters: { isRootSpan: [true, false] },
})
.expect(200);
const res = await agent
.get(`/pinned-filters?source=${sourceId}`)
.expect(200);
expect(res.body.team.filters).toEqual({ isRootSpan: [true, false] });
});
});
});

View file

@ -4,6 +4,8 @@ import {
makeSavedSearchAlertInput,
} from '@/fixtures';
import Alert from '@/models/alert';
import { SavedSearch } from '@/models/savedSearch';
import User from '@/models/user';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
const MOCK_SAVED_SEARCH = {
@ -127,6 +129,66 @@ describe('savedSearch router', () => {
expect(await Alert.findById(alert.body.data._id)).toBeNull();
});
it('sets createdBy and updatedBy on create and populates them in GET', async () => {
const created = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
.expect(200);
// GET all saved searches
const savedSearches = await agent.get('/saved-search').expect(200);
const savedSearch = savedSearches.body.find(
s => s._id === created.body._id,
);
expect(savedSearch.createdBy).toMatchObject({ email: user.email });
expect(savedSearch.updatedBy).toMatchObject({ email: user.email });
});
it('populates updatedBy with a different user after DB update', async () => {
const created = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
.expect(200);
// Create a second user on the same team
const secondUser = await User.create({
email: 'second@test.com',
name: 'Second User',
team: team._id,
});
// Simulate a different user updating the saved search
await SavedSearch.findByIdAndUpdate(created.body._id, {
updatedBy: secondUser._id,
});
const savedSearches = await agent.get('/saved-search').expect(200);
const savedSearch = savedSearches.body.find(
s => s._id === created.body._id,
);
expect(savedSearch.createdBy).toMatchObject({ email: user.email });
expect(savedSearch.updatedBy).toMatchObject({
email: 'second@test.com',
});
});
it('updates updatedBy when updating a saved search via API', async () => {
const created = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
.expect(200);
await agent
.patch(`/saved-search/${created.body._id}`)
.send({ name: 'updated name' })
.expect(200);
// Verify updatedBy is still set in the DB
const dbRecord = await SavedSearch.findById(created.body._id);
expect(dbRecord?.updatedBy?.toString()).toBe(user._id.toString());
expect(dbRecord?.createdBy?.toString()).toBe(user._id.toString());
});
it('sets createdBy on alerts created from a saved search and populates it in list', async () => {
// Create a saved search
const savedSearch = await agent

View file

@ -31,11 +31,11 @@ describe('team router', () => {
expect(_.omit(resp.body, ['_id', 'id', 'apiKey', 'createdAt']))
.toMatchInlineSnapshot(`
Object {
"allowedAuthMethods": Array [],
"name": "fake@deploysentinel.com's Team",
}
`);
{
"allowedAuthMethods": [],
"name": "fake@deploysentinel.com's Team",
}
`);
});
it('GET /team/tags - no tags', async () => {
@ -43,7 +43,7 @@ Object {
const resp = await agent.get('/team/tags').expect(200);
expect(resp.body.data).toMatchInlineSnapshot(`Array []`);
expect(resp.body.data).toMatchInlineSnapshot(`[]`);
});
it('GET /team/tags', async () => {
@ -98,29 +98,27 @@ Object {
});
const resp = await agent.get('/team/members').expect(200);
expect(resp.body.data).toMatchInlineSnapshot(`
Array [
Object {
"_id": "${resp.body.data[0]._id}",
"email": "fake@deploysentinel.com",
"hasPasswordAuth": true,
"isCurrentUser": true,
"name": "fake@deploysentinel.com",
},
Object {
"_id": "${user1._id}",
"email": "user1@example.com",
"hasPasswordAuth": true,
"isCurrentUser": false,
},
Object {
"_id": "${user2._id}",
"email": "user2@example.com",
"hasPasswordAuth": true,
"isCurrentUser": false,
},
]
`);
expect(resp.body.data.map(({ _id, ...rest }: any) => rest))
.toMatchInlineSnapshot(`
[
{
"email": "fake@deploysentinel.com",
"hasPasswordAuth": true,
"isCurrentUser": true,
"name": "fake@deploysentinel.com",
},
{
"email": "user1@example.com",
"hasPasswordAuth": true,
"isCurrentUser": false,
},
{
"email": "user2@example.com",
"hasPasswordAuth": true,
"isCurrentUser": false,
},
]
`);
});
it('POST /team/invitation', async () => {
@ -236,17 +234,17 @@ Array [
name: i.name,
})),
).toMatchInlineSnapshot(`
Array [
Object {
"email": "user1@example.com",
"name": "User 1",
},
Object {
"email": "user2@example.com",
"name": "User 2",
},
]
`);
[
{
"email": "user1@example.com",
"name": "User 1",
},
{
"email": "user2@example.com",
"name": "User 2",
},
]
`);
});
it('DELETE /team/member/:userId removes a user', async () => {

View file

@ -1,24 +1,91 @@
import type { AlertsApiResponse } from '@hyperdx/common-utils/dist/types';
import type {
AlertApiResponse,
AlertsApiResponse,
AlertsPageItem,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { pick } from 'lodash';
import { ObjectId } from 'mongodb';
import { z } from 'zod';
import { processRequest, validateRequest } from 'zod-express-middleware';
import { getRecentAlertHistoriesBatch } from '@/controllers/alertHistory';
import {
getRecentAlertHistories,
getRecentAlertHistoriesBatch,
} from '@/controllers/alertHistory';
import {
createAlert,
deleteAlert,
getAlertById,
getAlertEnhanced,
getAlertsEnhanced,
updateAlert,
validateAlertInput,
} from '@/controllers/alerts';
import { sendJson } from '@/utils/serialization';
import { IAlertHistory } from '@/models/alertHistory';
import { PreSerialized, sendJson } from '@/utils/serialization';
import { alertSchema, objectIdSchema } from '@/utils/zod';
const router = express.Router();
type EnhancedAlert = NonNullable<Awaited<ReturnType<typeof getAlertEnhanced>>>;
const formatAlertResponse = (
alert: EnhancedAlert,
history: Omit<IAlertHistory, 'alert'>[],
): PreSerialized<AlertsPageItem> => {
return {
history,
silenced: alert.silenced
? {
by: alert.silenced.by?.email,
at: alert.silenced.at,
until: alert.silenced.until,
}
: undefined,
createdBy: alert.createdBy
? pick(alert.createdBy, ['email', 'name'])
: undefined,
channel: pick(alert.channel, ['type']),
...(alert.dashboard && {
dashboardId: alert.dashboard._id,
dashboard: {
tiles: alert.dashboard.tiles
.filter(tile => tile.id === alert.tileId)
.map(tile => ({
id: tile.id,
config: { name: tile.config.name },
})),
...pick(alert.dashboard, ['_id', 'updatedAt', 'name', 'tags']),
},
}),
...(alert.savedSearch && {
savedSearchId: alert.savedSearch._id,
savedSearch: pick(alert.savedSearch, [
'_id',
'createdAt',
'name',
'updatedAt',
'tags',
]),
}),
...pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdMax',
'thresholdType',
'state',
'source',
'tileId',
'createdAt',
'updatedAt',
]),
};
};
type AlertsExpRes = express.Response<AlertsApiResponse>;
router.get('/', async (req, res: AlertsExpRes, next) => {
try {
@ -39,63 +106,50 @@ router.get('/', async (req, res: AlertsExpRes, next) => {
const data = alerts.map(alert => {
const history = historyMap.get(alert._id.toString()) ?? [];
return {
history,
silenced: alert.silenced
? {
by: alert.silenced.by?.email,
at: alert.silenced.at,
until: alert.silenced.until,
}
: undefined,
createdBy: alert.createdBy
? pick(alert.createdBy, ['email', 'name'])
: undefined,
channel: pick(alert.channel, ['type']),
...(alert.dashboard && {
dashboardId: alert.dashboard._id,
dashboard: {
tiles: alert.dashboard.tiles
.filter(tile => tile.id === alert.tileId)
.map(tile => ({
id: tile.id,
config: { name: tile.config.name },
})),
...pick(alert.dashboard, ['_id', 'updatedAt', 'name', 'tags']),
},
}),
...(alert.savedSearch && {
savedSearchId: alert.savedSearch._id,
savedSearch: pick(alert.savedSearch, [
'_id',
'createdAt',
'name',
'updatedAt',
'tags',
]),
}),
...pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdType',
'state',
'source',
'tileId',
'createdAt',
'updatedAt',
]),
};
return formatAlertResponse(alert, history);
});
sendJson(res, { data });
} catch (e) {
next(e);
}
});
type AlertExpRes = express.Response<AlertApiResponse>;
router.get(
'/:id',
validateRequest({
params: z.object({
id: objectIdSchema,
}),
}),
async (req, res: AlertExpRes, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
return res.sendStatus(403);
}
const alert = await getAlertEnhanced(req.params.id, teamId);
if (!alert) {
return res.sendStatus(404);
}
const history = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: alert.interval,
limit: 20,
});
const data = formatAlertResponse(alert, history);
sendJson(res, { data });
} catch (e) {
next(e);
}
},
);
router.post(
'/',
processRequest({ body: alertSchema }),

View file

@ -0,0 +1,95 @@
import { PinnedFiltersValueSchema } from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';
import {
getPinnedFilters,
updatePinnedFilters,
} from '@/controllers/pinnedFilter';
import { getSource } from '@/controllers/sources';
import { getNonNullUserWithTeam } from '@/middleware/auth';
import { objectIdSchema } from '@/utils/zod';
const router = express.Router();
/**
* GET /pinned-filters?source=<sourceId>
* Returns the team-level pinned filters for the source.
*/
router.get(
'/',
validateRequest({
query: z.object({
source: objectIdSchema,
}),
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { source } = req.query;
// Verify the source belongs to this team
const sourceDoc = await getSource(teamId.toString(), source);
if (!sourceDoc) {
return res.status(404).json({ error: 'Source not found' });
}
const doc = await getPinnedFilters(teamId.toString(), source);
return res.json({
team: doc
? {
id: doc._id.toString(),
fields: doc.fields,
filters: doc.filters,
}
: null,
});
} catch (e) {
next(e);
}
},
);
const updateBodySchema = z.object({
source: objectIdSchema,
fields: z.array(z.string().max(1024)).max(100),
filters: PinnedFiltersValueSchema,
});
/**
* PUT /pinned-filters
* Upserts team-level pinned filters for the given source.
*/
router.put(
'/',
validateRequest({ body: updateBodySchema }),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { source, fields, filters } = req.body;
// Verify the source belongs to this team
const sourceDoc = await getSource(teamId.toString(), source);
if (!sourceDoc) {
return res.status(404).json({ error: 'Source not found' });
}
const doc = await updatePinnedFilters(teamId.toString(), source, {
fields,
filters,
});
return res.json({
id: doc._id.toString(),
fields: doc.fields,
filters: doc.filters,
});
} catch (e) {
next(e);
}
},
);
export default router;

View file

@ -1,4 +1,7 @@
import { SavedSearchSchema } from '@hyperdx/common-utils/dist/types';
import {
SavedSearchListApiResponse,
SavedSearchSchema,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';
import _ from 'lodash';
import { z } from 'zod';
@ -16,7 +19,9 @@ import { objectIdSchema } from '@/utils/zod';
const router = express.Router();
router.get('/', async (req, res, next) => {
type SavedSearchListExpRes = express.Response<SavedSearchListApiResponse[]>;
router.get('/', async (req, res: SavedSearchListExpRes, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
@ -37,9 +42,13 @@ router.post(
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { teamId, userId } = getNonNullUserWithTeam(req);
const savedSearch = await createSavedSearch(teamId.toString(), req.body);
const savedSearch = await createSavedSearch(
teamId.toString(),
req.body,
userId?.toString(),
);
return res.json(savedSearch);
} catch (e) {
@ -60,7 +69,7 @@ router.patch(
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { teamId, userId } = getNonNullUserWithTeam(req);
const savedSearch = await getSavedSearch(
teamId.toString(),
@ -82,6 +91,7 @@ router.patch(
source: savedSearch.source.toString(),
...updates,
},
userId?.toString(),
);
if (!updatedSavedSearch) {

View file

@ -2,7 +2,12 @@ import _ from 'lodash';
import { ObjectId } from 'mongodb';
import request from 'supertest';
import { getLoggedInAgent, getServer } from '../../../fixtures';
import {
getLoggedInAgent,
getServer,
RAW_SQL_ALERT_TEMPLATE,
RAW_SQL_NUMBER_ALERT_TEMPLATE,
} from '../../../fixtures';
import { AlertSource, AlertThresholdType } from '../../../models/alert';
import Alert from '../../../models/alert';
import Dashboard from '../../../models/dashboard';
@ -83,8 +88,13 @@ describe('External API Alerts', () => {
};
// Helper to create a dashboard with a raw SQL tile for testing
// Uses Number display type by default (not alertable) for rejection tests
const createTestDashboardWithRawSqlTile = async (
options: { teamId?: any } = {},
options: {
teamId?: any;
displayType?: string;
sqlTemplate?: string;
} = {},
) => {
const tileId = new ObjectId().toString();
const tiles = [
@ -97,8 +107,8 @@ describe('External API Alerts', () => {
h: 3,
config: {
configType: 'sql',
displayType: 'line',
sqlTemplate: 'SELECT 1',
displayType: options.displayType ?? 'number',
sqlTemplate: options.sqlTemplate ?? 'SELECT 1',
connection: 'test-connection',
},
},
@ -716,9 +726,66 @@ describe('External API Alerts', () => {
.expect(400);
});
it('should reject creating an alert on a raw SQL tile', async () => {
it('should allow creating an alert on a raw SQL line tile', async () => {
const webhook = await createTestWebhook();
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile();
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
displayType: 'line',
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
});
const alertInput = {
dashboardId: dashboard._id.toString(),
tileId,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
const res = await authRequest('post', ALERTS_BASE_URL)
.send(alertInput)
.expect(200);
expect(res.body.data.dashboardId).toBe(dashboard._id.toString());
expect(res.body.data.tileId).toBe(tileId);
});
it('should allow creating an alert on a raw SQL number tile', async () => {
const webhook = await createTestWebhook();
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
displayType: 'number',
sqlTemplate: RAW_SQL_NUMBER_ALERT_TEMPLATE,
});
const alertInput = {
dashboardId: dashboard._id.toString(),
tileId,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
const res = await authRequest('post', ALERTS_BASE_URL)
.send(alertInput)
.expect(200);
expect(res.body.data.dashboardId).toBe(dashboard._id.toString());
expect(res.body.data.tileId).toBe(tileId);
});
it('should reject creating an alert on a raw SQL table tile', async () => {
const webhook = await createTestWebhook();
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
displayType: 'table',
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
});
const alertInput = {
dashboardId: dashboard._id.toString(),
@ -736,10 +803,36 @@ describe('External API Alerts', () => {
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});
it('should reject updating an alert to reference a raw SQL tile', async () => {
it('should reject creating an alert on a raw SQL tile without interval params', async () => {
const webhook = await createTestWebhook();
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
displayType: 'line',
sqlTemplate: 'SELECT count() FROM otel_logs',
});
const alertInput = {
dashboardId: dashboard._id.toString(),
tileId,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});
it('should reject updating an alert to reference a raw SQL table tile', async () => {
const { alert, webhook } = await createTestAlert();
const { dashboard: rawSqlDashboard, tileId: rawSqlTileId } =
await createTestDashboardWithRawSqlTile();
await createTestDashboardWithRawSqlTile({
displayType: 'table',
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
});
const updatePayload = {
threshold: 200,
@ -872,6 +965,197 @@ describe('External API Alerts', () => {
});
});
describe('BETWEEN and NOT_BETWEEN threshold types', () => {
it('should create an alert with BETWEEN threshold type', async () => {
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();
const response = await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 50,
thresholdMax: 200,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.BETWEEN,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(200);
const alert = response.body.data;
expect(alert.threshold).toBe(50);
expect(alert.thresholdMax).toBe(200);
expect(alert.thresholdType).toBe(AlertThresholdType.BETWEEN);
});
it('should create an alert with NOT_BETWEEN threshold type', async () => {
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();
const response = await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 10,
thresholdMax: 90,
interval: '5m',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.NOT_BETWEEN,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(200);
const alert = response.body.data;
expect(alert.threshold).toBe(10);
expect(alert.thresholdMax).toBe(90);
expect(alert.thresholdType).toBe(AlertThresholdType.NOT_BETWEEN);
});
it('should reject BETWEEN without thresholdMax', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();
await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 50,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.BETWEEN,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(400);
consoleErrorSpy.mockRestore();
});
it('should reject BETWEEN when thresholdMax < threshold', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();
await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 100,
thresholdMax: 50,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.BETWEEN,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(400);
consoleErrorSpy.mockRestore();
});
it('should allow thresholdMax equal to threshold for BETWEEN', async () => {
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();
const response = await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 100,
thresholdMax: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.BETWEEN,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(200);
expect(response.body.data.threshold).toBe(100);
expect(response.body.data.thresholdMax).toBe(100);
});
it('should update an alert to use BETWEEN threshold type', async () => {
const { alert, dashboard, webhook } = await createTestAlert();
const updateResponse = await authRequest(
'put',
`${ALERTS_BASE_URL}/${alert.id}`,
)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 20,
thresholdMax: 80,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.BETWEEN,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(200);
const updatedAlert = updateResponse.body.data;
expect(updatedAlert.threshold).toBe(20);
expect(updatedAlert.thresholdMax).toBe(80);
expect(updatedAlert.thresholdType).toBe(AlertThresholdType.BETWEEN);
});
it('should retrieve a BETWEEN alert with thresholdMax', async () => {
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();
const createResponse = await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 10,
thresholdMax: 50,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.BETWEEN,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(200);
const getResponse = await authRequest(
'get',
`${ALERTS_BASE_URL}/${createResponse.body.data.id}`,
).expect(200);
expect(getResponse.body.data.threshold).toBe(10);
expect(getResponse.body.data.thresholdMax).toBe(50);
expect(getResponse.body.data.thresholdType).toBe(
AlertThresholdType.BETWEEN,
);
});
});
describe('Authentication', () => {
it('should require authentication', async () => {
// Create an unauthenticated agent

View file

@ -3387,7 +3387,7 @@ describe('External API v2 Dashboards - new format', () => {
});
});
it('should delete alert when tile is updated from builder to raw SQL config', async () => {
it('should delete alert when tile is updated from builder to raw SQL config and the display type does not support alerts', async () => {
const tileId = new ObjectId().toString();
const dashboard = await createTestDashboard({
tiles: [
@ -3399,7 +3399,7 @@ describe('External API v2 Dashboards - new format', () => {
w: 6,
h: 3,
config: {
displayType: 'line',
displayType: 'number',
source: traceSource._id.toString(),
select: [
{
@ -3455,7 +3455,7 @@ describe('External API v2 Dashboards - new format', () => {
h: 3,
config: {
configType: 'sql',
displayType: 'line',
displayType: 'table',
connectionId: connection._id.toString(),
sqlTemplate: 'SELECT count() FROM otel_logs WHERE {timeFilter}',
},

View file

@ -34,7 +34,7 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* description: Evaluation interval.
* AlertThresholdType:
* type: string
* enum: [above, below]
* enum: [above, below, above_exclusive, below_or_equal, equal, not_equal, between, not_between]
* description: Threshold comparison direction.
* AlertSource:
* type: string
@ -95,7 +95,7 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* example: "65f5e4a3b9e77c001a567890"
* tileId:
* type: string
* description: Tile ID for tile-based alerts. May not be a Raw-SQL-based tile.
* description: Tile ID for tile-based alerts. Must be a line, stacked bar, or number type tile.
* nullable: true
* example: "65f5e4a3b9e77c001a901234"
* savedSearchId:
@ -110,8 +110,13 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* example: "ServiceName"
* threshold:
* type: number
* description: Threshold value for triggering the alert.
* description: Threshold value for triggering the alert. For between and not_between threshold types, this is the lower bound.
* example: 100
* thresholdMax:
* type: number
* nullable: true
* description: Upper bound for between and not_between threshold types. Required when thresholdType is between or not_between, must be >= threshold.
* example: 500
* interval:
* $ref: '#/components/schemas/AlertInterval'
* description: Evaluation interval for the alert.

View file

@ -1,104 +1,30 @@
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { uniq } from 'lodash';
import { ObjectId } from 'mongodb';
import mongoose from 'mongoose';
import { z } from 'zod';
import { deleteDashboardAlerts } from '@/controllers/alerts';
import { getConnectionsByTeam } from '@/controllers/connection';
import { deleteDashboard } from '@/controllers/dashboard';
import { getSources } from '@/controllers/sources';
import Dashboard from '@/models/dashboard';
import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors';
import {
translateExternalChartToTileConfig,
translateExternalFilterToFilter,
} from '@/utils/externalApi';
import logger from '@/utils/logger';
import {
ExternalDashboardFilter,
externalDashboardFilterSchema,
externalDashboardFilterSchemaWithId,
ExternalDashboardFilterWithId,
externalDashboardSavedFilterValueSchema,
externalDashboardTileListSchema,
ExternalDashboardTileWithId,
objectIdSchema,
tagsSchema,
} from '@/utils/zod';
import { ExternalDashboardTileWithId, objectIdSchema } from '@/utils/zod';
import {
cleanupDashboardAlerts,
convertExternalFiltersToInternal,
convertExternalTilesToInternal,
convertToExternalDashboard,
convertToInternalTileConfig,
createDashboardBodySchema,
getMissingConnections,
getMissingSources,
isConfigTile,
isRawSqlExternalTileConfig,
isSeriesTile,
resolveSavedQueryLanguage,
updateDashboardBodySchema,
} from './utils/dashboards';
/** Returns an array of source IDs that are referenced in the tiles/filters but do not exist in the team's sources */
async function getMissingSources(
team: string | mongoose.Types.ObjectId,
tiles: ExternalDashboardTileWithId[],
filters?: (ExternalDashboardFilter | ExternalDashboardFilterWithId)[],
): Promise<string[]> {
const sourceIds = new Set<string>();
for (const tile of tiles) {
if (isSeriesTile(tile)) {
for (const series of tile.series) {
if ('sourceId' in series) {
sourceIds.add(series.sourceId);
}
}
} else if (isConfigTile(tile)) {
if ('sourceId' in tile.config && tile.config.sourceId) {
sourceIds.add(tile.config.sourceId);
}
}
}
if (filters?.length) {
for (const filter of filters) {
if ('sourceId' in filter) {
sourceIds.add(filter.sourceId);
}
}
}
const existingSources = await getSources(team.toString());
const existingSourceIds = new Set(
existingSources.map(source => source._id.toString()),
);
return [...sourceIds].filter(sourceId => !existingSourceIds.has(sourceId));
}
/** Returns an array of connection IDs that are referenced in the tiles but do not belong to the team */
async function getMissingConnections(
team: string | mongoose.Types.ObjectId,
tiles: ExternalDashboardTileWithId[],
): Promise<string[]> {
const connectionIds = new Set<string>();
for (const tile of tiles) {
if (isConfigTile(tile) && isRawSqlExternalTileConfig(tile.config)) {
connectionIds.add(tile.config.connectionId);
}
}
if (connectionIds.size === 0) return [];
const existingConnections = await getConnectionsByTeam(team.toString());
const existingConnectionIds = new Set(
existingConnections.map(connection => connection._id.toString()),
);
return [...connectionIds].filter(
connectionId => !existingConnectionIds.has(connectionId),
);
}
async function getSourceConnectionMismatches(
team: string | mongoose.Types.ObjectId,
tiles: ExternalDashboardTileWithId[],
@ -123,62 +49,6 @@ async function getSourceConnectionMismatches(
return sourcesWithInvalidConnections;
}
type SavedQueryLanguage = z.infer<typeof whereLanguageSchema>;
function resolveSavedQueryLanguage(params: {
savedQuery: string | null | undefined;
savedQueryLanguage: SavedQueryLanguage | null | undefined;
}): SavedQueryLanguage | null | undefined {
const { savedQuery, savedQueryLanguage } = params;
if (savedQueryLanguage !== undefined) return savedQueryLanguage;
if (savedQuery === null) return null;
if (savedQuery) return 'lucene';
return undefined;
}
const dashboardBodyBaseShape = {
name: z.string().max(1024),
tiles: externalDashboardTileListSchema,
tags: tagsSchema,
savedQuery: z.string().nullable().optional(),
savedQueryLanguage: whereLanguageSchema.nullable().optional(),
savedFilterValues: z
.array(externalDashboardSavedFilterValueSchema)
.optional(),
};
function buildDashboardBodySchema(filterSchema: z.ZodTypeAny): z.ZodEffects<
z.ZodObject<
typeof dashboardBodyBaseShape & {
filters: z.ZodOptional<z.ZodArray<z.ZodTypeAny>>;
}
>
> {
return z
.object({
...dashboardBodyBaseShape,
filters: z.array(filterSchema).optional(),
})
.superRefine((data, ctx) => {
if (data.savedQuery != null && data.savedQueryLanguage === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'savedQueryLanguage cannot be null when savedQuery is provided',
path: ['savedQueryLanguage'],
});
}
});
}
const createDashboardBodySchema = buildDashboardBodySchema(
externalDashboardFilterSchema,
);
const updateDashboardBodySchema = buildDashboardBodySchema(
externalDashboardFilterSchemaWithId,
);
/**
* @openapi
* components:
@ -1748,27 +1618,8 @@ router.post(
});
}
const internalTiles = tiles.map(tile => {
const tileId = new ObjectId().toString();
if (isConfigTile(tile)) {
return convertToInternalTileConfig({
...tile,
id: tileId,
});
}
return translateExternalChartToTileConfig({
...tile,
id: tileId,
});
});
const filtersWithIds = (filters || []).map(filter =>
translateExternalFilterToFilter({
...filter,
id: new ObjectId().toString(),
}),
);
const internalTiles = convertExternalTilesToInternal(tiles);
const filtersWithIds = convertExternalFiltersToInternal(filters || []);
const normalizedSavedQueryLanguage = resolveSavedQueryLanguage({
savedQuery,
@ -2001,18 +1852,10 @@ router.put(
(existingDashboard?.filters ?? []).map((f: { id: string }) => f.id),
);
// Convert external tiles to internal charts format.
// Generate a new id for any tile whose id doesn't match an existing tile.
const internalTiles = tiles.map(tile => {
const tileId = existingTileIds.has(tile.id)
? tile.id
: new ObjectId().toString();
if (isConfigTile(tile)) {
return convertToInternalTileConfig({ ...tile, id: tileId });
}
return translateExternalChartToTileConfig({ ...tile, id: tileId });
});
const internalTiles = convertExternalTilesToInternal(
tiles,
existingTileIds,
);
const setPayload: Record<string, unknown> = {
name,
@ -2020,13 +1863,9 @@ router.put(
tags: tags && uniq(tags),
};
if (filters !== undefined) {
setPayload.filters = filters.map(
(filter: ExternalDashboardFilterWithId) => {
const filterId = existingFilterIds.has(filter.id)
? filter.id
: new ObjectId().toString();
return translateExternalFilterToFilter({ ...filter, id: filterId });
},
setPayload.filters = convertExternalFiltersToInternal(
filters,
existingFilterIds,
);
}
if (savedQuery !== undefined) {
@ -2053,21 +1892,12 @@ router.put(
return res.sendStatus(404);
}
// Delete alerts for tiles that are now raw SQL (unsupported) or were removed
const newTileIdSet = new Set(internalTiles.map(t => t.id));
const tileIdsToDeleteAlerts = [
...internalTiles
.filter(tile => isRawSqlSavedChartConfig(tile.config))
.map(tile => tile.id),
...[...existingTileIds].filter(id => !newTileIdSet.has(id)),
];
if (tileIdsToDeleteAlerts.length > 0) {
logger.info(
{ dashboardId, teamId, tileIds: tileIdsToDeleteAlerts },
`Deleting alerts for tiles with unsupported config or removed tiles`,
);
await deleteDashboardAlerts(dashboardId, teamId, tileIdsToDeleteAlerts);
}
await cleanupDashboardAlerts({
dashboardId,
teamId,
internalTiles,
existingTileIds,
});
res.json({
data: convertToExternalDashboard(updatedDashboard),

Some files were not shown because too many files have changed in this diff Show more