mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
Merge remote-tracking branch 'origin/main' into teeohhem/expand-e2e-coverage
This commit is contained in:
commit
65b93f49ca
367 changed files with 34870 additions and 6021 deletions
5
.changeset/add-alerts-page.md
Normal file
5
.changeset/add-alerts-page.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/cli": patch
|
||||
---
|
||||
|
||||
Add alerts page (Shift+A) with overview and recent trigger history
|
||||
5
.changeset/add-cli-pattern-mining.md
Normal file
5
.changeset/add-cli-pattern-mining.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/cli": patch
|
||||
---
|
||||
|
||||
Add event pattern mining view (Shift+P) with sampled estimation and drill-down
|
||||
5
.changeset/add-drain-library.md
Normal file
5
.changeset/add-drain-library.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
---
|
||||
|
||||
Add Drain log template mining library (ported from browser-drain)
|
||||
8
.changeset/bump-otel-collector-to-0149.md
Normal file
8
.changeset/bump-otel-collector-to-0149.md
Normal 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).
|
||||
10
.changeset/cli-app-url-migration.md
Normal file
10
.changeset/cli-app-url-migration.md
Normal 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`
|
||||
5
.changeset/cool-pants-train.md
Normal file
5
.changeset/cool-pants-train.md
Normal 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.
|
||||
11
.changeset/deprecate-clickhouse-json-feature-gate.md
Normal file
11
.changeset/deprecate-clickhouse-json-feature-gate.md
Normal 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.
|
||||
7
.changeset/early-ducks-grow.md
Normal file
7
.changeset/early-ducks-grow.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Support alerts on Raw SQL Number Charts
|
||||
5
.changeset/fix-copy-row-json-button.md
Normal file
5
.changeset/fix-copy-row-json-button.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
fix: Fix "Copy entire row as JSON" button crashing on rows with non-string values
|
||||
5
.changeset/hdx-3908-validation-toast-dedupe.md
Normal file
5
.changeset/hdx-3908-validation-toast-dedupe.md
Normal 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.
|
||||
9
.changeset/healthy-eyes-kiss.md
Normal file
9
.changeset/healthy-eyes-kiss.md
Normal 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
|
||||
12
.changeset/migrate-otel-collector-to-ocb.md
Normal file
12
.changeset/migrate-otel-collector-to-ocb.md
Normal 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.
|
||||
5
.changeset/open-trace-in-browser.md
Normal file
5
.changeset/open-trace-in-browser.md
Normal 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.
|
||||
5
.changeset/optimize-trace-waterfall.md
Normal file
5
.changeset/optimize-trace-waterfall.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/cli": patch
|
||||
---
|
||||
|
||||
Optimize event detail and trace waterfall queries; add trace detail page and waterfall scrolling
|
||||
13
.changeset/otel-collector-add-core-extensions.md
Normal file
13
.changeset/otel-collector-add-core-extensions.md
Normal 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.
|
||||
8
.changeset/polite-grapes-cross.md
Normal file
8
.changeset/polite-grapes-cross.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
"@hyperdx/cli": patch
|
||||
---
|
||||
|
||||
feat: Add additional alert threshold types
|
||||
7
.changeset/serious-chicken-hammer.md
Normal file
7
.changeset/serious-chicken-hammer.md
Normal 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.
|
||||
5
.changeset/shaggy-tigers-tan.md
Normal file
5
.changeset/shaggy-tigers-tan.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add Python Runtime Metrics dashboard template
|
||||
5
.changeset/sharp-emus-reflect.md
Normal file
5
.changeset/sharp-emus-reflect.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
---
|
||||
|
||||
fix: Skip rendering empty SQL dashboard filter
|
||||
7
.changeset/short-badgers-applaud.md
Normal file
7
.changeset/short-badgers-applaud.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Implement alerting for Raw SQL-based dashboard tiles
|
||||
5
.changeset/short-tools-sleep.md
Normal file
5
.changeset/short-tools-sleep.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
fix: time selector always resets to 00:00
|
||||
7
.changeset/silly-toes-cough.md
Normal file
7
.changeset/silly-toes-cough.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add alert history + ack to alert editor
|
||||
5
.changeset/thirty-students-exist.md
Normal file
5
.changeset/thirty-students-exist.md
Normal 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.
|
||||
5
.changeset/upgrade-mantine-v9.md
Normal file
5
.changeset/upgrade-mantine-v9.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hyperdx/app': minor
|
||||
---
|
||||
|
||||
Upgrade Mantine from v7 to v9 and remove react-hook-form-mantine dependency
|
||||
|
|
@ -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
10
.env
|
|
@ -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
|
||||
|
|
|
|||
569
.github/scripts/__tests__/pr-triage-classify.test.js
vendored
Normal file
569
.github/scripts/__tests__/pr-triage-classify.test.js
vendored
Normal 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
257
.github/scripts/pr-triage-classify.js
vendored
Normal 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: 5–15 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
123
.github/scripts/pr-triage.js
vendored
Normal 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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
252
.github/workflows/pr-triage.yml
vendored
252
.github/workflows/pr-triage.yml
vendored
|
|
@ -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: 5–15 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 });
|
||||
|
|
|
|||
101
.github/workflows/release.yml
vendored
101
.github/workflows/release.yml
vendored
|
|
@ -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,
|
||||
|
|
|
|||
43
AGENTS.md
43
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
89
MCP.md
Normal 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 |
|
||||
4
Makefile
4
Makefile
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"project": ["src/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"ignore": ["scripts/dev-portal/**"],
|
||||
"ignore": ["scripts/dev-portal/**", ".github/scripts/**"],
|
||||
"ignoreBinaries": ["make", "migrate", "playwright"],
|
||||
"ignoreDependencies": [
|
||||
"@dotenvx/dotenvx",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
42
packages/api/src/controllers/pinnedFilter.ts
Normal file
42
packages/api/src/controllers/pinnedFilter.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
545
packages/api/src/mcp/__tests__/dashboards.test.ts
Normal file
545
packages/api/src/mcp/__tests__/dashboards.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
packages/api/src/mcp/__tests__/mcpTestUtils.ts
Normal file
53
packages/api/src/mcp/__tests__/mcpTestUtils.ts
Normal 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;
|
||||
}
|
||||
74
packages/api/src/mcp/__tests__/query.test.ts
Normal file
74
packages/api/src/mcp/__tests__/query.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
233
packages/api/src/mcp/__tests__/queryTool.test.ts
Normal file
233
packages/api/src/mcp/__tests__/queryTool.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
160
packages/api/src/mcp/__tests__/tracing.test.ts
Normal file
160
packages/api/src/mcp/__tests__/tracing.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
58
packages/api/src/mcp/app.ts
Normal file
58
packages/api/src/mcp/app.ts
Normal 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;
|
||||
21
packages/api/src/mcp/mcpServer.ts
Normal file
21
packages/api/src/mcp/mcpServer.ts
Normal 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;
|
||||
}
|
||||
719
packages/api/src/mcp/prompts/dashboards/content.ts
Normal file
719
packages/api/src/mcp/prompts/dashboards/content.ts
Normal 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.`;
|
||||
}
|
||||
48
packages/api/src/mcp/prompts/dashboards/helpers.ts
Normal file
48
packages/api/src/mcp/prompts/dashboards/helpers.ts
Normal 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>';
|
||||
}
|
||||
195
packages/api/src/mcp/prompts/dashboards/index.ts
Normal file
195
packages/api/src/mcp/prompts/dashboards/index.ts
Normal 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;
|
||||
63
packages/api/src/mcp/tools/dashboards/deleteDashboard.ts
Normal file
63
packages/api/src/mcp/tools/dashboards/deleteDashboard.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
87
packages/api/src/mcp/tools/dashboards/getDashboard.ts
Normal file
87
packages/api/src/mcp/tools/dashboards/getDashboard.ts
Normal 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,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
23
packages/api/src/mcp/tools/dashboards/index.ts
Normal file
23
packages/api/src/mcp/tools/dashboards/index.ts
Normal 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;
|
||||
183
packages/api/src/mcp/tools/dashboards/listSources.ts
Normal file
183
packages/api/src/mcp/tools/dashboards/listSources.ts
Normal 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) },
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
97
packages/api/src/mcp/tools/dashboards/queryTile.ts
Normal file
97
packages/api/src/mcp/tools/dashboards/queryTile.ts
Normal 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
338
packages/api/src/mcp/tools/dashboards/saveDashboard.ts
Normal file
338
packages/api/src/mcp/tools/dashboards/saveDashboard.ts
Normal 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,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
320
packages/api/src/mcp/tools/dashboards/schemas.ts
Normal file
320
packages/api/src/mcp/tools/dashboards/schemas.ts
Normal 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 (0–23). 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 (1–24). 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 (0–10). 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 } } }',
|
||||
);
|
||||
264
packages/api/src/mcp/tools/query/helpers.ts
Normal file
264
packages/api/src/mcp/tools/query/helpers.ts
Normal 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);
|
||||
}
|
||||
117
packages/api/src/mcp/tools/query/index.ts
Normal file
117
packages/api/src/mcp/tools/query/index.ts
Normal 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;
|
||||
220
packages/api/src/mcp/tools/query/schemas.ts
Normal file
220
packages/api/src/mcp/tools/query/schemas.ts
Normal 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 (1–200). 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,
|
||||
]);
|
||||
10
packages/api/src/mcp/tools/types.ts
Normal file
10
packages/api/src/mcp/tools/types.ts
Normal 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;
|
||||
83
packages/api/src/mcp/utils/tracing.ts
Normal file
83
packages/api/src/mcp/utils/tracing.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
49
packages/api/src/models/pinnedFilter.ts
Normal file
49
packages/api/src/models/pinnedFilter.ts
Normal 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,
|
||||
);
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
214
packages/api/src/routers/api/__tests__/pinnedFilters.test.ts
Normal file
214
packages/api/src/routers/api/__tests__/pinnedFilters.test.ts
Normal 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] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
95
packages/api/src/routers/api/pinnedFilters.ts
Normal file
95
packages/api/src/routers/api/pinnedFilters.ts
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue