@contrail/cli 3.5.0-alpha.d435e43 → 3.5.0-alpha.dd20a19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ $ npm install -g @contrail/cli
17
17
  $ contrail COMMAND
18
18
  running command...
19
19
  $ contrail (--version)
20
- @contrail/cli/3.5.0-alpha.d435e43 linux-x64 node-v24.14.1
20
+ @contrail/cli/3.5.0-alpha.dd20a19 linux-x64 node-v24.14.1
21
21
  $ contrail --help [COMMAND]
22
22
  USAGE
23
23
  $ contrail COMMAND
@@ -51,6 +51,7 @@ To get instructions on how to set up autocomplete for your shell, run `contrail
51
51
  * [`contrail config list`](#contrail-config-list)
52
52
  * [`contrail config set`](#contrail-config-set)
53
53
  * [`contrail config use [NAME]`](#contrail-config-use-name)
54
+ * [`contrail event-workflows assess-impact IDENTIFIER`](#contrail-event-workflows-assess-impact-identifier)
54
55
  * [`contrail file upload FILELOCATION CONTENTTYPE`](#contrail-file-upload-filelocation-contenttype)
55
56
  * [`contrail groups export`](#contrail-groups-export)
56
57
  * [`contrail groups import GROUPSFILE`](#contrail-groups-import-groupsfile)
@@ -536,6 +537,37 @@ EXAMPLES
536
537
  $ contrail config use demo-org
537
538
  ```
538
539
 
540
+ ## `contrail event-workflows assess-impact IDENTIFIER`
541
+
542
+ Sample Dash0 traces of an event-workflow action and report per-invocation downstream impact.
543
+
544
+ ```
545
+ USAGE
546
+ $ contrail event-workflows assess-impact IDENTIFIER [-l error|warn|info|debug] [-e true|false] [-s <value>] [--lookback <value>]
547
+ [--dataset <value>] [-j]
548
+
549
+ ARGUMENTS
550
+ IDENTIFIER Action identifier (contrail.event_workflow.action.identifier).
551
+
552
+ FLAGS
553
+ -e, --alwaysConfirmEnv=<option> [default: true] The Environment variables
554
+ <options: true|false>
555
+ -j, --json JSON output (not yet implemented; flag reserved).
556
+ -l, --logLevel=<option> The Log Level
557
+ <options: error|warn|info|debug>
558
+ -s, --samples=<value> [default: 50] Number of distinct traces to sample (10-100).
559
+ --dataset=<value> [default: prod] Dash0 dataset.
560
+ --lookback=<value> [default: 2d] How far back to search. Format: 30m | 6h | 2d.
561
+
562
+ DESCRIPTION
563
+ Sample Dash0 traces of an event-workflow action and report per-invocation downstream impact.
564
+
565
+ EXAMPLES
566
+ $ contrail event-workflows assess-impact @gap-automations:bomCcCostUpdate
567
+
568
+ $ contrail event-workflows assess-impact @gap-automations:bomCcCostUpdate --samples 25 --lookback 6h
569
+ ```
570
+
539
571
  ## `contrail file upload FILELOCATION CONTENTTYPE`
540
572
 
541
573
  Uploads a file to the VibeIQ platform
@@ -0,0 +1,13 @@
1
+ import { GetSpansRequest, GetSpansResponse } from './types';
2
+ export interface Dash0ClientOptions {
3
+ token: string;
4
+ dataset: string;
5
+ apiUrl?: string;
6
+ }
7
+ export declare class Dash0Client {
8
+ private readonly http;
9
+ private readonly dataset;
10
+ constructor(opts: Dash0ClientOptions);
11
+ getSpans(req: GetSpansRequest): Promise<GetSpansResponse>;
12
+ }
13
+ export declare function dash0ClientFromEnv(dataset: string): Dash0Client;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ // Minimal Dash0 REST client.
3
+ //
4
+ // Contract verified by hand against api.us-west-2.aws.dash0.com on 2026-05-08:
5
+ // POST /api/spans
6
+ // headers: Authorization: Bearer ${DASH0_TOKEN}
7
+ // Content-Type: application/json
8
+ // Dash0-Dataset: <dataset> (REQUIRED — body field is ignored)
9
+ // body: { filter?: AttributeFilter[], timeRange: {from,to}, pagination?: {limit,cursor} }
10
+ // NOTE: the field is "filter" (singular). "filters" is silently ignored.
11
+ // Response uses OTLP/JSON: traceId/spanId are base64.
12
+ // Pagination via cursors.after → next call's pagination.cursor.
13
+ //
14
+ // Trace expansion uses the same endpoint: filter on `otel.trace.id` (hex).
15
+ // There is no separate trace-details endpoint (the binary's /api/trace-details path 404s).
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.dash0ClientFromEnv = exports.Dash0Client = void 0;
18
+ const tslib_1 = require("tslib");
19
+ const axios_1 = tslib_1.__importDefault(require("axios"));
20
+ const DEFAULT_API_URL = 'https://api.us-west-2.aws.dash0.com';
21
+ class Dash0Client {
22
+ http;
23
+ dataset;
24
+ constructor(opts) {
25
+ this.dataset = opts.dataset;
26
+ this.http = axios_1.default.create({
27
+ baseURL: opts.apiUrl ?? DEFAULT_API_URL,
28
+ headers: {
29
+ Authorization: `Bearer ${opts.token}`,
30
+ 'Content-Type': 'application/json',
31
+ 'Dash0-Dataset': opts.dataset,
32
+ },
33
+ timeout: 30000,
34
+ });
35
+ }
36
+ async getSpans(req) {
37
+ const { data } = await this.http.post('/api/spans', req);
38
+ return data;
39
+ }
40
+ }
41
+ exports.Dash0Client = Dash0Client;
42
+ function dash0ClientFromEnv(dataset) {
43
+ const token = process.env.DASH0_TOKEN;
44
+ if (!token) {
45
+ throw new Error('DASH0_TOKEN environment variable is required. Create one at https://app.dash0.com/settings/auth-tokens.');
46
+ }
47
+ return new Dash0Client({
48
+ token,
49
+ dataset,
50
+ apiUrl: process.env.DASH0_API_URL,
51
+ });
52
+ }
53
+ exports.dash0ClientFromEnv = dash0ClientFromEnv;
@@ -0,0 +1,8 @@
1
+ import { Dash0Client } from './client';
2
+ import { AttributeFilter, FlatSpan, OtlpAttribute, OtlpResourceSpans, OtlpSpan, TimeReferenceRange } from './types';
3
+ export declare function paginateSpans(client: Dash0Client, filter: AttributeFilter[], timeRange: TimeReferenceRange, limit?: number): AsyncGenerator<OtlpResourceSpans, void, void>;
4
+ export declare function sampleTraceIdsForAction(client: Dash0Client, actionIdentifier: string, timeRange: TimeReferenceRange, maxTraces: number): Promise<string[]>;
5
+ export declare function fetchTraceSpans(client: Dash0Client, traceIdHex: string, timeRange: TimeReferenceRange): Promise<FlatSpan[]>;
6
+ export declare function toFlat(sp: OtlpSpan, resourceAttrs: Record<string, string | number | boolean>): FlatSpan;
7
+ export declare function flattenAttrs(attrs: OtlpAttribute[]): Record<string, string | number | boolean>;
8
+ export declare function b64ToHex(b64: string): string;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.b64ToHex = exports.flattenAttrs = exports.toFlat = exports.fetchTraceSpans = exports.sampleTraceIdsForAction = exports.paginateSpans = void 0;
4
+ const PAGE_LIMIT = 50;
5
+ const MAX_PAGES_PER_QUERY = 200;
6
+ async function* paginateSpans(client, filter, timeRange, limit = PAGE_LIMIT) {
7
+ let cursor;
8
+ for (let page = 0; page < MAX_PAGES_PER_QUERY; page++) {
9
+ const req = {
10
+ filter,
11
+ timeRange,
12
+ pagination: { limit, cursor },
13
+ };
14
+ const resp = await client.getSpans(req);
15
+ for (const r of resp.resourceSpans ?? [])
16
+ yield r;
17
+ cursor = resp.cursors?.after;
18
+ if (!cursor)
19
+ return;
20
+ }
21
+ }
22
+ exports.paginateSpans = paginateSpans;
23
+ async function sampleTraceIdsForAction(client, actionIdentifier, timeRange, maxTraces) {
24
+ const seen = new Set();
25
+ for await (const rs of paginateSpans(client, [
26
+ {
27
+ key: 'contrail.event_workflow.action.identifier',
28
+ operator: 'is',
29
+ value: actionIdentifier,
30
+ },
31
+ ], timeRange)) {
32
+ for (const ss of rs.scopeSpans ?? []) {
33
+ for (const sp of ss.spans ?? []) {
34
+ seen.add(b64ToHex(sp.traceId));
35
+ if (seen.size >= maxTraces)
36
+ return [...seen];
37
+ }
38
+ }
39
+ }
40
+ return [...seen];
41
+ }
42
+ exports.sampleTraceIdsForAction = sampleTraceIdsForAction;
43
+ async function fetchTraceSpans(client, traceIdHex, timeRange) {
44
+ const out = [];
45
+ for await (const rs of paginateSpans(client, [{ key: 'otel.trace.id', operator: 'is', value: traceIdHex }], timeRange)) {
46
+ const resourceAttrs = flattenAttrs(rs.resource?.attributes ?? []);
47
+ for (const ss of rs.scopeSpans ?? []) {
48
+ for (const sp of ss.spans ?? [])
49
+ out.push(toFlat(sp, resourceAttrs));
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ exports.fetchTraceSpans = fetchTraceSpans;
55
+ function toFlat(sp, resourceAttrs) {
56
+ const startNs = BigInt(sp.startTimeUnixNano);
57
+ const endNs = BigInt(sp.endTimeUnixNano);
58
+ return {
59
+ name: sp.name,
60
+ traceId: b64ToHex(sp.traceId),
61
+ spanId: b64ToHex(sp.spanId),
62
+ parentSpanId: sp.parentSpanId ? b64ToHex(sp.parentSpanId) : '',
63
+ kind: sp.kind,
64
+ startNs,
65
+ endNs,
66
+ durationNs: endNs - startNs,
67
+ attrs: flattenAttrs(sp.attributes ?? []),
68
+ resourceAttrs,
69
+ };
70
+ }
71
+ exports.toFlat = toFlat;
72
+ function flattenAttrs(attrs) {
73
+ const out = {};
74
+ for (const a of attrs) {
75
+ const v = a.value ?? {};
76
+ if (v.stringValue !== undefined)
77
+ out[a.key] = v.stringValue;
78
+ else if (v.intValue !== undefined)
79
+ out[a.key] = Number(v.intValue);
80
+ else if (v.boolValue !== undefined)
81
+ out[a.key] = v.boolValue;
82
+ else if (v.doubleValue !== undefined)
83
+ out[a.key] = v.doubleValue;
84
+ }
85
+ return out;
86
+ }
87
+ exports.flattenAttrs = flattenAttrs;
88
+ function b64ToHex(b64) {
89
+ return Buffer.from(b64, 'base64').toString('hex');
90
+ }
91
+ exports.b64ToHex = b64ToHex;
@@ -0,0 +1,84 @@
1
+ export type AttributeFilterOperator = 'is' | 'is_not' | 'is_set' | 'is_not_set' | 'is_one_of' | 'is_not_one_of' | 'gt' | 'gte' | 'lt' | 'lte' | 'matches' | 'does_not_match' | 'contains' | 'does_not_contain' | 'starts_with' | 'does_not_start_with' | 'ends_with' | 'does_not_end_with';
2
+ export interface AttributeFilter {
3
+ key: string;
4
+ operator: AttributeFilterOperator;
5
+ value?: string;
6
+ values?: string[];
7
+ }
8
+ export interface TimeReferenceRange {
9
+ from: string;
10
+ to: string;
11
+ }
12
+ export interface CursorPagination {
13
+ limit?: number;
14
+ cursor?: string;
15
+ }
16
+ export interface GetSpansRequest {
17
+ filter?: AttributeFilter[];
18
+ timeRange: TimeReferenceRange;
19
+ pagination?: CursorPagination;
20
+ }
21
+ export interface OtlpAttribute {
22
+ key: string;
23
+ value: {
24
+ stringValue?: string;
25
+ intValue?: string | number;
26
+ boolValue?: boolean;
27
+ doubleValue?: number;
28
+ arrayValue?: {
29
+ values: OtlpAttribute['value'][];
30
+ };
31
+ };
32
+ }
33
+ export interface OtlpSpan {
34
+ name: string;
35
+ traceId: string;
36
+ spanId: string;
37
+ parentSpanId: string;
38
+ kind: number;
39
+ startTimeUnixNano: string;
40
+ endTimeUnixNano: string;
41
+ attributes: OtlpAttribute[];
42
+ status: {
43
+ code: number;
44
+ message?: string;
45
+ };
46
+ events?: unknown[];
47
+ links?: unknown[];
48
+ }
49
+ export interface OtlpScopeSpans {
50
+ scope?: {
51
+ name?: string;
52
+ version?: string;
53
+ };
54
+ spans: OtlpSpan[];
55
+ }
56
+ export interface OtlpResourceSpans {
57
+ resource: {
58
+ attributes: OtlpAttribute[];
59
+ };
60
+ scopeSpans: OtlpScopeSpans[];
61
+ }
62
+ export interface NextCursors {
63
+ after?: string;
64
+ before?: string;
65
+ }
66
+ export interface GetSpansResponse {
67
+ resourceSpans: OtlpResourceSpans[];
68
+ cursors?: NextCursors;
69
+ executionTime?: string;
70
+ }
71
+ export interface FlatSpan {
72
+ name: string;
73
+ traceId: string;
74
+ spanId: string;
75
+ parentSpanId: string;
76
+ kind: number;
77
+ startNs: bigint;
78
+ endNs: bigint;
79
+ durationNs: bigint;
80
+ attrs: Record<string, string | number | boolean>;
81
+ resourceAttrs: Record<string, string | number | boolean>;
82
+ depthFromAction?: number;
83
+ serverAncestorCount?: number;
84
+ }
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ // OTLP/JSON span shape returned by Dash0 POST /api/spans.
3
+ // Field types match https://github.com/dash0hq/dash0-api-client-go (generated.go).
4
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,19 @@
1
+ import { BaseCommand } from '../../basecommand';
2
+ export default class AssessImpact extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ identifier: import("@oclif/core/lib/interfaces/parser").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ samples: import("@oclif/core/lib/interfaces").OptionFlag<number, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
10
+ lookback: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
11
+ dataset: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
12
+ json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
13
+ alwaysConfirmEnv: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
14
+ logLevel: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
15
+ };
16
+ skipValidateEnv: boolean;
17
+ skipConfirmEnv: boolean;
18
+ run(): Promise<void>;
19
+ }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const core_1 = require("@oclif/core");
4
+ const basecommand_1 = require("../../basecommand");
5
+ const client_1 = require("../../api/dash0/client");
6
+ const spans_1 = require("../../api/dash0/spans");
7
+ const subtree_1 = require("../../helpers/event-workflow-impact/subtree");
8
+ const aggregate_1 = require("../../helpers/event-workflow-impact/aggregate");
9
+ const render_1 = require("../../helpers/event-workflow-impact/render");
10
+ const LOOKBACK_PATTERN = /^\d+(?:m|h|d)$/;
11
+ class AssessImpact extends basecommand_1.BaseCommand {
12
+ static description = "Sample Dash0 traces of an event-workflow action and report per-invocation downstream impact.";
13
+ static examples = [
14
+ 'contrail event-workflows assess-impact @gap-automations:bomCcCostUpdate',
15
+ 'contrail event-workflows assess-impact @gap-automations:bomCcCostUpdate --samples 25 --lookback 6h',
16
+ ];
17
+ static args = {
18
+ identifier: core_1.Args.string({
19
+ description: 'Action identifier (contrail.event_workflow.action.identifier).',
20
+ required: true,
21
+ }),
22
+ };
23
+ static flags = {
24
+ ...basecommand_1.BaseCommand.baseFlags,
25
+ samples: core_1.Flags.integer({
26
+ char: 's',
27
+ description: 'Number of distinct traces to sample (10-100).',
28
+ default: 50,
29
+ min: 10,
30
+ max: 100,
31
+ }),
32
+ lookback: core_1.Flags.string({
33
+ description: 'How far back to search. Format: 30m | 6h | 2d.',
34
+ default: '2d',
35
+ }),
36
+ dataset: core_1.Flags.string({
37
+ description: 'Dash0 dataset.',
38
+ default: 'prod',
39
+ }),
40
+ json: core_1.Flags.boolean({
41
+ char: 'j',
42
+ description: 'JSON output (not yet implemented; flag reserved).',
43
+ default: false,
44
+ }),
45
+ };
46
+ // No vibeiq auth or env confirmation — this hits Dash0, not the contrail API.
47
+ skipValidateEnv = true;
48
+ skipConfirmEnv = true;
49
+ async run() {
50
+ const { identifier } = this.args;
51
+ const { samples, lookback, dataset } = this.flags;
52
+ if (!LOOKBACK_PATTERN.test(lookback)) {
53
+ this.error(`--lookback must look like 30m, 6h, or 2d (got: ${lookback}).`);
54
+ }
55
+ const client = (0, client_1.dash0ClientFromEnv)(dataset);
56
+ const timeRange = { from: `now-${lookback}`, to: 'now' };
57
+ this.log(`Action: ${identifier}`);
58
+ this.log(`Dataset: ${dataset}`);
59
+ this.log(`Lookback: ${lookback}`);
60
+ this.log(`Samples: target ${samples}`);
61
+ this.log('');
62
+ this.log('Sampling trace IDs...');
63
+ const traceIds = await (0, spans_1.sampleTraceIdsForAction)(client, identifier, timeRange, samples);
64
+ if (traceIds.length === 0) {
65
+ this.warn(`No spans found for action.identifier="${identifier}" in the last ${lookback}. ` +
66
+ 'Verify the identifier and dataset.');
67
+ return;
68
+ }
69
+ this.log(`Got ${traceIds.length} trace(s). Fetching subtrees...`);
70
+ const perTraceDescendants = [];
71
+ let dropped = 0;
72
+ let totalOrphans = 0;
73
+ let preciseRootCount = 0;
74
+ for (const traceId of traceIds) {
75
+ const spans = await (0, spans_1.fetchTraceSpans)(client, traceId, timeRange);
76
+ const result = (0, subtree_1.extractActionSubtree)(spans, identifier);
77
+ if (!result) {
78
+ dropped++;
79
+ continue;
80
+ }
81
+ perTraceDescendants.push(result.descendants);
82
+ totalOrphans += result.orphanCount;
83
+ if (result.preciseRoot)
84
+ preciseRootCount++;
85
+ }
86
+ if (perTraceDescendants.length === 0) {
87
+ this.warn('No traces yielded a usable action subtree.');
88
+ return;
89
+ }
90
+ const agg = (0, aggregate_1.aggregate)({ perTraceDescendants });
91
+ this.log('');
92
+ this.log((0, render_1.renderReport)(agg, {
93
+ identifier,
94
+ dataset,
95
+ lookback,
96
+ samplesRequested: samples,
97
+ samplesObtained: perTraceDescendants.length,
98
+ tracesDropped: dropped,
99
+ totalOrphanCount: totalOrphans,
100
+ preciseRootCount,
101
+ }));
102
+ }
103
+ }
104
+ exports.default = AssessImpact;
@@ -0,0 +1,23 @@
1
+ import { FlatSpan } from '../../api/dash0/types';
2
+ import { Category } from './classify';
3
+ export interface BucketKey {
4
+ category: Category;
5
+ service: string;
6
+ operation: string;
7
+ }
8
+ export interface BucketStats {
9
+ key: BucketKey;
10
+ median: number;
11
+ p95: number;
12
+ max: number;
13
+ coverage: number;
14
+ }
15
+ export interface AggregateInput {
16
+ perTraceDescendants: FlatSpan[][];
17
+ }
18
+ export interface AggregateResult {
19
+ traceCount: number;
20
+ buckets: BucketStats[];
21
+ }
22
+ export declare function aggregate(input: AggregateInput): AggregateResult;
23
+ export declare function percentile(values: number[], p: number): number;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.percentile = exports.aggregate = void 0;
4
+ const classify_1 = require("./classify");
5
+ const keyId = (k) => `${k.category}\x1f${k.service}\x1f${k.operation}`;
6
+ function aggregate(input) {
7
+ const traceCount = input.perTraceDescendants.length;
8
+ // bucketKey -> per-trace count vector (length = traceCount)
9
+ const counts = new Map();
10
+ input.perTraceDescendants.forEach((spans, traceIdx) => {
11
+ for (const span of spans) {
12
+ const c = (0, classify_1.classifySpan)(span);
13
+ if (!c)
14
+ continue;
15
+ bumpBucket(counts, c, traceIdx, traceCount);
16
+ }
17
+ });
18
+ const buckets = [];
19
+ for (const { key, counts: vec } of counts.values()) {
20
+ buckets.push({
21
+ key,
22
+ median: percentile(vec, 0.5),
23
+ p95: percentile(vec, 0.95),
24
+ max: vec.reduce((a, b) => Math.max(a, b), 0),
25
+ coverage: vec.filter((n) => n > 0).length / Math.max(traceCount, 1),
26
+ });
27
+ }
28
+ return { traceCount, buckets };
29
+ }
30
+ exports.aggregate = aggregate;
31
+ function bumpBucket(counts, c, traceIdx, traceCount) {
32
+ const k = { category: c.category, service: c.service, operation: c.operation };
33
+ const id = keyId(k);
34
+ let entry = counts.get(id);
35
+ if (!entry) {
36
+ entry = { key: k, counts: new Array(traceCount).fill(0) };
37
+ counts.set(id, entry);
38
+ }
39
+ entry.counts[traceIdx] += 1;
40
+ }
41
+ function percentile(values, p) {
42
+ if (values.length === 0)
43
+ return 0;
44
+ const sorted = [...values].sort((a, b) => a - b);
45
+ // Nearest-rank, clamped.
46
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
47
+ return sorted[idx];
48
+ }
49
+ exports.percentile = percentile;
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const aggregate_1 = require("./aggregate");
4
+ function clientHttp(target, service = 'contrail-items') {
5
+ return {
6
+ name: 'GET',
7
+ traceId: 't',
8
+ spanId: Math.random().toString(36),
9
+ parentSpanId: '',
10
+ kind: 3,
11
+ startNs: 0n,
12
+ endNs: 0n,
13
+ durationNs: 0n,
14
+ attrs: { 'http.request.method': 'GET', 'http.target': target },
15
+ resourceAttrs: { 'service.name': service },
16
+ depthFromAction: 1,
17
+ };
18
+ }
19
+ function dynamoQuery() {
20
+ return {
21
+ name: 'DynamoDB.Query',
22
+ traceId: 't',
23
+ spanId: Math.random().toString(36),
24
+ parentSpanId: '',
25
+ kind: 4,
26
+ startNs: 0n,
27
+ endNs: 0n,
28
+ durationNs: 0n,
29
+ attrs: {},
30
+ resourceAttrs: {},
31
+ };
32
+ }
33
+ describe('percentile', () => {
34
+ it('handles empty arrays', () => {
35
+ expect((0, aggregate_1.percentile)([], 0.5)).toBe(0);
36
+ });
37
+ it('computes nearest-rank percentiles', () => {
38
+ expect((0, aggregate_1.percentile)([0, 0, 1, 1, 2, 2, 3, 3, 4, 4], 0.5)).toBe(2);
39
+ expect((0, aggregate_1.percentile)([0, 0, 1, 1, 2, 2, 3, 3, 4, 4], 0.95)).toBe(4);
40
+ });
41
+ });
42
+ describe('aggregate', () => {
43
+ it('counts occurrences per trace and computes median/p95/max/coverage', () => {
44
+ // 4 traces; varying fan-out for two HTTP buckets and one dynamo bucket.
45
+ const perTrace = [
46
+ // trace 1: 2 GET /items, 1 PUT /batch, 5 dynamo
47
+ [
48
+ clientHttp('/api/items'),
49
+ clientHttp('/api/items'),
50
+ { ...clientHttp('/api/items/batch'), name: 'PUT', attrs: { 'http.request.method': 'PUT', 'http.target': '/api/items/batch' } },
51
+ dynamoQuery(),
52
+ dynamoQuery(),
53
+ dynamoQuery(),
54
+ dynamoQuery(),
55
+ dynamoQuery(),
56
+ ],
57
+ // trace 2: 4 GET /items, no batch, 2 dynamo
58
+ [
59
+ clientHttp('/api/items'),
60
+ clientHttp('/api/items'),
61
+ clientHttp('/api/items'),
62
+ clientHttp('/api/items'),
63
+ dynamoQuery(),
64
+ dynamoQuery(),
65
+ ],
66
+ // trace 3: empty action subtree
67
+ [],
68
+ // trace 4: 1 GET /items
69
+ [clientHttp('/api/items')],
70
+ ];
71
+ const result = (0, aggregate_1.aggregate)({ perTraceDescendants: perTrace });
72
+ expect(result.traceCount).toBe(4);
73
+ const items = result.buckets.find((b) => b.key.category === 'http_direct' && b.key.operation === 'GET /api/items');
74
+ expect(items).toBeDefined();
75
+ // counts vector [2,4,0,1] sorted [0,1,2,4]; nearest-rank p50=1, p95=4.
76
+ expect(items.median).toBe(1);
77
+ expect(items.p95).toBe(4);
78
+ expect(items.max).toBe(4);
79
+ expect(items.coverage).toBeCloseTo(0.75, 5);
80
+ const dynamo = result.buckets.find((b) => b.key.category === 'dynamodb');
81
+ expect(dynamo).toBeDefined();
82
+ // [5,2,0,0] sorted [0,0,2,5]; nearest-rank p50=0, p95=5.
83
+ expect(dynamo.median).toBe(0);
84
+ expect(dynamo.p95).toBe(5);
85
+ expect(dynamo.max).toBe(5);
86
+ expect(dynamo.coverage).toBeCloseTo(0.5, 5);
87
+ });
88
+ it('skips SERVER spans (classified to null)', () => {
89
+ const result = (0, aggregate_1.aggregate)({
90
+ perTraceDescendants: [
91
+ [
92
+ {
93
+ name: 'GET /prod/api/items',
94
+ traceId: 't',
95
+ spanId: 's',
96
+ parentSpanId: '',
97
+ kind: 2,
98
+ startNs: 0n,
99
+ endNs: 0n,
100
+ durationNs: 0n,
101
+ attrs: { 'http.request.method': 'GET', 'http.target': '/api/items' },
102
+ resourceAttrs: {},
103
+ },
104
+ ],
105
+ ],
106
+ });
107
+ expect(result.buckets).toHaveLength(0);
108
+ });
109
+ });
@@ -0,0 +1,13 @@
1
+ import { FlatSpan } from '../../api/dash0/types';
2
+ export type Category = 'http_direct' | 'http_transitive' | 'opensearch' | 'dynamodb' | 's3' | 'timestream' | 'lambda' | 'other';
3
+ export declare const CATEGORY_ORDER: Category[];
4
+ export declare const CATEGORY_LABEL: Record<Category, string>;
5
+ export interface Classified {
6
+ category: Category;
7
+ service: string;
8
+ operation: string;
9
+ }
10
+ export declare function classifySpan(span: FlatSpan): Classified | null;
11
+ export declare function templatePath(target: string): string;
12
+ export declare function looksLikeId(seg: string): boolean;
13
+ export declare function normalizeRouteName(name: string): string;