@contrail/cli 3.5.0-alpha.11f30e6 → 3.5.0-alpha.7efeaa8

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.11f30e6 linux-x64 node-v24.14.1
20
+ @contrail/cli/3.5.0-alpha.7efeaa8 linux-x64 node-v24.14.1
21
21
  $ contrail --help [COMMAND]
22
22
  USAGE
23
23
  $ contrail COMMAND
@@ -1,12 +1,7 @@
1
1
  import { Dash0Client } from './client';
2
2
  import { AttributeFilter, FlatSpan, OtlpAttribute, OtlpResourceSpans, OtlpSpan, TimeReferenceRange } from './types';
3
3
  export declare function paginateSpans(client: Dash0Client, filter: AttributeFilter[], timeRange: TimeReferenceRange, limit?: number): AsyncGenerator<OtlpResourceSpans, void, void>;
4
- export interface SampledTrace {
5
- traceId: string;
6
- anchorNs: bigint;
7
- }
8
- export declare function sampleTraceIdsForAction(client: Dash0Client, actionIdentifier: string, timeRange: TimeReferenceRange, maxTraces: number): Promise<SampledTrace[]>;
9
- export declare function bracketWindowAround(anchorNs: bigint, halfWindowMin?: number): TimeReferenceRange;
4
+ export declare function sampleTraceIdsForAction(client: Dash0Client, actionIdentifier: string, timeRange: TimeReferenceRange, maxTraces: number): Promise<string[]>;
10
5
  export declare function fetchTraceSpans(client: Dash0Client, traceIdHex: string, timeRange: TimeReferenceRange): Promise<FlatSpan[]>;
11
6
  export declare function toFlat(sp: OtlpSpan, resourceAttrs: Record<string, string | number | boolean>): FlatSpan;
12
7
  export declare function flattenAttrs(attrs: OtlpAttribute[]): Record<string, string | number | boolean>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.b64ToHex = exports.flattenAttrs = exports.toFlat = exports.fetchTraceSpans = exports.bracketWindowAround = exports.sampleTraceIdsForAction = exports.paginateSpans = void 0;
3
+ exports.b64ToHex = exports.flattenAttrs = exports.toFlat = exports.fetchTraceSpans = exports.sampleTraceIdsForAction = exports.paginateSpans = void 0;
4
4
  const PAGE_LIMIT = 50;
5
5
  const MAX_PAGES_PER_QUERY = 200;
6
6
  async function* paginateSpans(client, filter, timeRange, limit = PAGE_LIMIT) {
@@ -21,8 +21,8 @@ async function* paginateSpans(client, filter, timeRange, limit = PAGE_LIMIT) {
21
21
  }
22
22
  exports.paginateSpans = paginateSpans;
23
23
  async function sampleTraceIdsForAction(client, actionIdentifier, timeRange, maxTraces) {
24
- const seen = new Map();
25
- outer: for await (const rs of paginateSpans(client, [
24
+ const seen = new Set();
25
+ for await (const rs of paginateSpans(client, [
26
26
  {
27
27
  key: 'contrail.event_workflow.action.identifier',
28
28
  operator: 'is',
@@ -31,33 +31,15 @@ async function sampleTraceIdsForAction(client, actionIdentifier, timeRange, maxT
31
31
  ], timeRange)) {
32
32
  for (const ss of rs.scopeSpans ?? []) {
33
33
  for (const sp of ss.spans ?? []) {
34
- const traceId = b64ToHex(sp.traceId);
35
- const startNs = BigInt(sp.startTimeUnixNano);
36
- const existing = seen.get(traceId);
37
- if (existing === undefined || startNs < existing) {
38
- seen.set(traceId, startNs);
39
- }
34
+ seen.add(b64ToHex(sp.traceId));
40
35
  if (seen.size >= maxTraces)
41
- break outer;
36
+ return [...seen];
42
37
  }
43
38
  }
44
39
  }
45
- return [...seen.entries()].map(([traceId, anchorNs]) => ({ traceId, anchorNs }));
40
+ return [...seen];
46
41
  }
47
42
  exports.sampleTraceIdsForAction = sampleTraceIdsForAction;
48
- // Build a Dash0 absolute time range bracketing a trace anchor. ±halfWindowMin
49
- // around the anchor in ISO RFC3339; resilient to clock drift between sample
50
- // and fetch and wide enough to capture the full trace.
51
- function bracketWindowAround(anchorNs, halfWindowMin = 5) {
52
- const halfMs = BigInt(halfWindowMin) * 60000n;
53
- const fromMs = Number((anchorNs - halfMs * 1000000n) / 1000000n);
54
- const toMs = Number((anchorNs + halfMs * 1000000n) / 1000000n);
55
- return {
56
- from: new Date(fromMs).toISOString(),
57
- to: new Date(toMs).toISOString(),
58
- };
59
- }
60
- exports.bracketWindowAround = bracketWindowAround;
61
43
  async function fetchTraceSpans(client, traceIdHex, timeRange) {
62
44
  const out = [];
63
45
  for await (const rs of paginateSpans(client, [{ key: 'otel.trace.id', operator: 'is', value: traceIdHex }], timeRange)) {
@@ -79,6 +79,4 @@ export interface FlatSpan {
79
79
  durationNs: bigint;
80
80
  attrs: Record<string, string | number | boolean>;
81
81
  resourceAttrs: Record<string, string | number | boolean>;
82
- depthFromAction?: number;
83
- serverAncestorCount?: number;
84
82
  }
@@ -60,31 +60,25 @@ class AssessImpact extends basecommand_1.BaseCommand {
60
60
  this.log(`Samples: target ${samples}`);
61
61
  this.log('');
62
62
  this.log('Sampling trace IDs...');
63
- const sampled = await (0, spans_1.sampleTraceIdsForAction)(client, identifier, timeRange, samples);
64
- if (sampled.length === 0) {
63
+ const traceIds = await (0, spans_1.sampleTraceIdsForAction)(client, identifier, timeRange, samples);
64
+ if (traceIds.length === 0) {
65
65
  this.warn(`No spans found for action.identifier="${identifier}" in the last ${lookback}. ` +
66
66
  'Verify the identifier and dataset.');
67
67
  return;
68
68
  }
69
- this.log(`Got ${sampled.length} trace(s). Fetching subtrees...`);
69
+ this.log(`Got ${traceIds.length} trace(s). Fetching subtrees...`);
70
70
  const perTraceDescendants = [];
71
- const actionDurationsNs = [];
72
71
  let dropped = 0;
73
72
  let totalOrphans = 0;
74
73
  let preciseRootCount = 0;
75
- for (const { traceId, anchorNs } of sampled) {
76
- // Bracket each trace fetch on the absolute timestamp captured at sample
77
- // time. Avoids sliding-window edge effects where a trace at the boundary
78
- // of the lookback window has its earliest spans excluded.
79
- const traceWindow = (0, spans_1.bracketWindowAround)(anchorNs);
80
- const spans = await (0, spans_1.fetchTraceSpans)(client, traceId, traceWindow);
74
+ for (const traceId of traceIds) {
75
+ const spans = await (0, spans_1.fetchTraceSpans)(client, traceId, timeRange);
81
76
  const result = (0, subtree_1.extractActionSubtree)(spans, identifier);
82
77
  if (!result) {
83
78
  dropped++;
84
79
  continue;
85
80
  }
86
81
  perTraceDescendants.push(result.descendants);
87
- actionDurationsNs.push(result.actionSpan.durationNs);
88
82
  totalOrphans += result.orphanCount;
89
83
  if (result.preciseRoot)
90
84
  preciseRootCount++;
@@ -104,7 +98,6 @@ class AssessImpact extends basecommand_1.BaseCommand {
104
98
  tracesDropped: dropped,
105
99
  totalOrphanCount: totalOrphans,
106
100
  preciseRootCount,
107
- actionDuration: (0, aggregate_1.summarizeDurations)(actionDurationsNs),
108
101
  }));
109
102
  }
110
103
  }
@@ -21,12 +21,3 @@ export interface AggregateResult {
21
21
  }
22
22
  export declare function aggregate(input: AggregateInput): AggregateResult;
23
23
  export declare function percentile(values: number[], p: number): number;
24
- export interface DurationStats {
25
- count: number;
26
- meanMs: number;
27
- p50Ms: number;
28
- p95Ms: number;
29
- p99Ms: number;
30
- maxMs: number;
31
- }
32
- export declare function summarizeDurations(durationsNs: bigint[]): DurationStats;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.summarizeDurations = exports.percentile = exports.aggregate = void 0;
3
+ exports.percentile = exports.aggregate = void 0;
4
4
  const classify_1 = require("./classify");
5
5
  const keyId = (k) => `${k.category}\x1f${k.service}\x1f${k.operation}`;
6
6
  function aggregate(input) {
@@ -47,19 +47,3 @@ function percentile(values, p) {
47
47
  return sorted[idx];
48
48
  }
49
49
  exports.percentile = percentile;
50
- function summarizeDurations(durationsNs) {
51
- const ms = durationsNs.map((n) => Number(n / 1000000n));
52
- if (ms.length === 0) {
53
- return { count: 0, meanMs: 0, p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0 };
54
- }
55
- const sum = ms.reduce((a, b) => a + b, 0);
56
- return {
57
- count: ms.length,
58
- meanMs: sum / ms.length,
59
- p50Ms: percentile(ms, 0.5),
60
- p95Ms: percentile(ms, 0.95),
61
- p99Ms: percentile(ms, 0.99),
62
- maxMs: ms.reduce((a, b) => Math.max(a, b), 0),
63
- };
64
- }
65
- exports.summarizeDurations = summarizeDurations;
@@ -13,7 +13,6 @@ function clientHttp(target, service = 'contrail-items') {
13
13
  durationNs: 0n,
14
14
  attrs: { 'http.request.method': 'GET', 'http.target': target },
15
15
  resourceAttrs: { 'service.name': service },
16
- depthFromAction: 1,
17
16
  };
18
17
  }
19
18
  function dynamoQuery() {
@@ -70,7 +69,7 @@ describe('aggregate', () => {
70
69
  ];
71
70
  const result = (0, aggregate_1.aggregate)({ perTraceDescendants: perTrace });
72
71
  expect(result.traceCount).toBe(4);
73
- const items = result.buckets.find((b) => b.key.category === 'http_direct' && b.key.operation === 'GET /api/items');
72
+ const items = result.buckets.find((b) => b.key.category === 'http' && b.key.operation === 'GET /api/items');
74
73
  expect(items).toBeDefined();
75
74
  // counts vector [2,4,0,1] sorted [0,1,2,4]; nearest-rank p50=1, p95=4.
76
75
  expect(items.median).toBe(1);
@@ -107,28 +106,3 @@ describe('aggregate', () => {
107
106
  expect(result.buckets).toHaveLength(0);
108
107
  });
109
108
  });
110
- describe('summarizeDurations', () => {
111
- it('returns zeros for empty input', () => {
112
- expect((0, aggregate_1.summarizeDurations)([])).toEqual({
113
- count: 0,
114
- meanMs: 0,
115
- p50Ms: 0,
116
- p95Ms: 0,
117
- p99Ms: 0,
118
- maxMs: 0,
119
- });
120
- });
121
- it('converts ns to ms and computes mean + nearest-rank percentiles', () => {
122
- // 10 values: 100ms, 200ms, ..., 1000ms.
123
- const ns = [];
124
- for (let i = 1; i <= 10; i++)
125
- ns.push(BigInt(i * 100) * 1000000n);
126
- const stats = (0, aggregate_1.summarizeDurations)(ns);
127
- expect(stats.count).toBe(10);
128
- expect(stats.meanMs).toBe(550); // sum=5500, n=10
129
- expect(stats.p50Ms).toBe(500); // ceil(0.5*10)-1 = 4 → sorted[4] = 500
130
- expect(stats.p95Ms).toBe(1000); // ceil(0.95*10)-1 = 9 → sorted[9] = 1000
131
- expect(stats.p99Ms).toBe(1000);
132
- expect(stats.maxMs).toBe(1000);
133
- });
134
- });
@@ -1,5 +1,5 @@
1
1
  import { FlatSpan } from '../../api/dash0/types';
2
- export type Category = 'http_direct' | 'http_transitive' | 'opensearch' | 'dynamodb' | 's3' | 'timestream' | 'lambda' | 'other';
2
+ export type Category = 'http' | 'opensearch' | 'dynamodb' | 's3' | 'timestream' | 'lambda' | 'other';
3
3
  export declare const CATEGORY_ORDER: Category[];
4
4
  export declare const CATEGORY_LABEL: Record<Category, string>;
5
5
  export interface Classified {
@@ -2,8 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeRouteName = exports.looksLikeId = exports.templatePath = exports.classifySpan = exports.CATEGORY_LABEL = exports.CATEGORY_ORDER = void 0;
4
4
  exports.CATEGORY_ORDER = [
5
- 'http_direct',
6
- 'http_transitive',
5
+ 'http',
7
6
  'opensearch',
8
7
  'dynamodb',
9
8
  's3',
@@ -12,8 +11,7 @@ exports.CATEGORY_ORDER = [
12
11
  'other',
13
12
  ];
14
13
  exports.CATEGORY_LABEL = {
15
- http_direct: 'HTTP — direct (called by the action)',
16
- http_transitive: 'HTTP — transitive (called by downstream services)',
14
+ http: 'HTTP',
17
15
  opensearch: 'OpenSearch',
18
16
  dynamodb: 'DynamoDB',
19
17
  s3: 'S3',
@@ -63,13 +61,8 @@ function classifySpan(span) {
63
61
  // when we have a path; otherwise normalize the leading verb in name so
64
62
  // "get /x" and "GET /x" don't split into two buckets.
65
63
  const operation = path ? `${method} ${path}` : normalizeRouteName(name);
66
- // Direct HTTP = called by the action's own code (no SERVER boundary
67
- // between this span and the action root). Transitive = inside a service
68
- // the action invoked (1+ SERVER ancestors). Spans without subtree info
69
- // (e.g. unit tests) default to direct.
70
- const serverAncestors = span.serverAncestorCount ?? 0;
71
64
  return {
72
- category: serverAncestors === 0 ? 'http_direct' : 'http_transitive',
65
+ category: 'http',
73
66
  service: service || 'unknown',
74
67
  operation,
75
68
  };
@@ -67,9 +67,7 @@ describe('classifySpan', () => {
67
67
  it('classifies Timestream AWS SDK spans', () => {
68
68
  expect((0, classify_1.classifySpan)(span({ name: 'TimestreamWrite.WriteRecords' }))?.category).toBe('timestream');
69
69
  });
70
- it('classifies HTTP with no SERVER ancestor as http_direct', () => {
71
- // Apps lambda's outbound HTTP CLIENT — sits below the action span via a
72
- // lambda-invoke CLIENT, no SERVER boundary in between.
70
+ it('classifies generic HTTP CLIENT spans, templating the path', () => {
73
71
  const s = span({
74
72
  name: 'GET',
75
73
  attrs: {
@@ -78,29 +76,13 @@ describe('classifySpan', () => {
78
76
  'http.host': 'api.vibeiq.com:443',
79
77
  },
80
78
  resourceAttrs: { 'service.name': 'contrail-event-workflow-apps' },
81
- serverAncestorCount: 0,
82
79
  });
83
80
  expect((0, classify_1.classifySpan)(s)).toEqual({
84
- category: 'http_direct',
81
+ category: 'http',
85
82
  service: 'contrail-event-workflow-apps',
86
83
  operation: 'GET /prod/api/items',
87
84
  });
88
85
  });
89
- it('classifies HTTP behind a SERVER boundary as http_transitive', () => {
90
- // Outbound HTTP from a downstream service the action called — there is a
91
- // SERVER span between this and the action.
92
- const s = span({
93
- name: 'GET',
94
- attrs: {
95
- 'http.request.method': 'GET',
96
- 'http.target': '/prod/api/open-search',
97
- 'http.host': 'api.vibeiq.com:443',
98
- },
99
- resourceAttrs: { 'service.name': 'contrail-items' },
100
- serverAncestorCount: 1,
101
- });
102
- expect((0, classify_1.classifySpan)(s)?.category).toBe('http_transitive');
103
- });
104
86
  it('classifies lambda-to-lambda invokes (no HTTP attrs, aws.lambda resource)', () => {
105
87
  const s = span({
106
88
  name: 'contrail-items-prod-api',
@@ -127,10 +109,8 @@ describe('classifySpan', () => {
127
109
  name: 'get /prod/api/custom-entities',
128
110
  attrs: { 'http.host': 'api.vibeiq.com:443' },
129
111
  resourceAttrs: { 'service.name': 'contrail-custom-entities' },
130
- serverAncestorCount: 1,
131
112
  });
132
113
  expect((0, classify_1.classifySpan)(lower)?.operation).toBe('GET /prod/api/custom-entities');
133
- expect((0, classify_1.classifySpan)(lower)?.category).toBe('http_transitive');
134
114
  });
135
115
  it('falls back to "other" for unknown CLIENT spans without aws.lambda resource', () => {
136
116
  expect((0, classify_1.classifySpan)(span({ name: 'mystery-step' }))?.category).toBe('other');
@@ -1,4 +1,4 @@
1
- import { AggregateResult, DurationStats } from './aggregate';
1
+ import { AggregateResult } from './aggregate';
2
2
  export interface RenderOptions {
3
3
  identifier: string;
4
4
  dataset: string;
@@ -8,6 +8,5 @@ export interface RenderOptions {
8
8
  tracesDropped: number;
9
9
  totalOrphanCount: number;
10
10
  preciseRootCount: number;
11
- actionDuration: DurationStats;
12
11
  }
13
12
  export declare function renderReport(result: AggregateResult, opts: RenderOptions): string;
@@ -15,15 +15,7 @@ function renderReport(result, opts) {
15
15
  lines.push(`Rooting: ${opts.preciseRootCount} precise / ${fallback} fallback (no inner action span — framework AWS-SDK direct children stripped heuristically)`);
16
16
  }
17
17
  lines.push('');
18
- const d = opts.actionDuration;
19
- if (d.count > 0) {
20
- const note = opts.preciseRootCount < opts.samplesObtained
21
- ? ' (includes some framework overhead on fallback-rooted traces)'
22
- : '';
23
- lines.push(`Action duration: mean ${fmtMs(d.meanMs)} · p50 ${fmtMs(d.p50Ms)} · p95 ${fmtMs(d.p95Ms)} · p99 ${fmtMs(d.p99Ms)} · max ${fmtMs(d.maxMs)}${note}`);
24
- lines.push('');
25
- }
26
- lines.push('Per-invocation downstream calls. "% of traces" is how often the bucket appears in any invocation; median/p95/max include zeros.');
18
+ lines.push('Per-invocation downstream calls (median / p95 / max / coverage):');
27
19
  lines.push('');
28
20
  const byCategory = new Map();
29
21
  for (const b of result.buckets) {
@@ -39,7 +31,7 @@ function renderReport(result, opts) {
39
31
  any = true;
40
32
  lines.push(classify_1.CATEGORY_LABEL[cat]);
41
33
  const table = new cli_table3_1.default({
42
- head: ['service', 'operation', 'median', 'p95', 'max', '% of traces'],
34
+ head: ['service', 'operation', 'median', 'p95', 'max', 'coverage'],
43
35
  style: { head: [], border: [] },
44
36
  });
45
37
  buckets
@@ -68,10 +60,3 @@ exports.renderReport = renderReport;
68
60
  function fmt(n) {
69
61
  return Number.isInteger(n) ? n.toString() : n.toFixed(1);
70
62
  }
71
- function fmtMs(ms) {
72
- if (ms < 1)
73
- return `${ms.toFixed(2)}ms`;
74
- if (ms < 1000)
75
- return `${Math.round(ms)}ms`;
76
- return `${(ms / 1000).toFixed(2)}s`;
77
- }
@@ -49,17 +49,9 @@ function extractActionSubtree(spans, identifier) {
49
49
  return null;
50
50
  const preciseRoot = !!inner;
51
51
  const descendants = [];
52
- // BFS tracking depth and "service boundary crossings" level-by-level. A
53
- // service boundary is a SERVER span (kind=2) that represents code running
54
- // in another service. The action's own executor SERVER — the SERVER span
55
- // emitted on the receive side of the lambda invocation that runs the
56
- // action — is NOT counted, because it represents the action's own
57
- // execution context, not downstream work.
58
- const queue = [
59
- { spanId: actionSpan.spanId, depth: 0, serverAncestors: 0 },
60
- ];
61
- while (queue.length) {
62
- const { spanId: parentId, depth, serverAncestors } = queue.shift();
52
+ const stack = [actionSpan.spanId];
53
+ while (stack.length) {
54
+ const parentId = stack.pop();
63
55
  const children = byParent.get(parentId) ?? [];
64
56
  for (const c of children) {
65
57
  // Fallback path: when rooting at the consumer, AWS-SDK spans that are a
@@ -70,30 +62,14 @@ function extractActionSubtree(spans, identifier) {
70
62
  if (!preciseRoot && parentId === actionSpan.spanId && isFrameworkAwsSdk(c)) {
71
63
  continue;
72
64
  }
73
- c.depthFromAction = depth + 1;
74
- c.serverAncestorCount = serverAncestors;
75
65
  descendants.push(c);
76
- // Skip the action's executor SERVER from boundary counting: a SERVER
77
- // span that is a *direct* child of a non-SERVER action span is the
78
- // server side of the invocation that runs the action's code itself.
79
- const isExecutorServer = c.kind === 2 &&
80
- parentId === actionSpan.spanId &&
81
- actionSpan.kind !== 2;
82
- const childServerCount = c.kind === 2 && !isExecutorServer ? serverAncestors + 1 : serverAncestors;
83
- queue.push({ spanId: c.spanId, depth: depth + 1, serverAncestors: childServerCount });
66
+ stack.push(c.spanId);
84
67
  }
85
68
  }
86
- // An orphan is a span whose parent was never observed AND whose time range
87
- // falls inside the action span's time range. Orphans outside that range are
88
- // unrelated trace spans (siblings of the action's lineage) and shouldn't
89
- // taint the propagation-gap warning.
90
69
  let orphanCount = 0;
91
70
  for (const s of spans) {
92
- if (!s.parentSpanId || byId.has(s.parentSpanId))
93
- continue;
94
- if (s.startNs < actionSpan.startNs || s.startNs > actionSpan.endNs)
95
- continue;
96
- orphanCount++;
71
+ if (s.parentSpanId && !byId.has(s.parentSpanId))
72
+ orphanCount++;
97
73
  }
98
74
  return { descendants, actionSpan, preciseRoot, orphanCount };
99
75
  }
@@ -20,42 +20,30 @@ describe('extractActionSubtree', () => {
20
20
  it('returns null when no candidate exists', () => {
21
21
  expect((0, subtree_1.extractActionSubtree)([s({ spanId: 'a' })], IDENT)).toBeNull();
22
22
  });
23
- describe('precise root (inner action span exists)', () => {
24
- it('does not count the action executor SERVER as a service boundary', () => {
25
- // Realistic Lambda shape: action span (kind=CLIENT, the invocation) has
26
- // a SERVER child representing the receive-side of that invocation —
27
- // this is the action's own executor lambda. The HTTP outbound from
28
- // inside that lambda must be classified as direct (serverAncestorCount=0).
29
- // Only SERVER spans BELOW outbound calls (downstream services) count.
23
+ describe('precise root (inner PRODUCER span exists)', () => {
24
+ it('walks descendants of the inner span and excludes consumer-side framework spans', () => {
25
+ // consumer (root) -> [framework DDB, framework S3, inner action]
26
+ // inner -> [http GET, http PUT]
27
+ // http GET -> nested DDB (downstream service's own DDB; should be kept)
30
28
  const spans = [
31
29
  s({
32
30
  spanId: 'consumer',
33
31
  parentSpanId: '',
34
32
  name: 'consumer',
35
- kind: 2,
36
33
  attrs: { 'contrail.event_workflow.action.identifier': IDENT },
37
34
  }),
38
- s({ spanId: 'inner', parentSpanId: 'consumer', name: IDENT, kind: 3 }),
39
- // Action's own executor SERVER (apps lambda receive side) — exempt.
40
- s({ spanId: 'execSrv', parentSpanId: 'inner', name: 'apps-lambda', kind: 2 }),
41
- // Action code's own outbound HTTP — direct.
42
- s({ spanId: 'http1', parentSpanId: 'execSrv', name: 'GET', kind: 3 }),
43
- // Downstream SERVER (e.g. contrail-items receiving the request).
44
- s({ spanId: 'downSrv', parentSpanId: 'http1', name: 'GET /api/items', kind: 2 }),
45
- // HTTP outbound from downstream — transitive.
46
- s({ spanId: 'http2', parentSpanId: 'downSrv', name: 'GET', kind: 3 }),
35
+ s({ spanId: 'frameworkDdb', parentSpanId: 'consumer', name: 'DynamoDB.Query' }),
36
+ s({ spanId: 'frameworkS3', parentSpanId: 'consumer', name: 'S3.PutObject' }),
37
+ s({ spanId: 'inner', parentSpanId: 'consumer', name: IDENT, kind: 4 }),
38
+ s({ spanId: 'http1', parentSpanId: 'inner', name: 'GET' }),
39
+ s({ spanId: 'http2', parentSpanId: 'inner', name: 'PUT' }),
40
+ s({ spanId: 'nestedDdb', parentSpanId: 'http1', name: 'DynamoDB.Query' }),
47
41
  ];
48
42
  const result = (0, subtree_1.extractActionSubtree)(spans, IDENT);
49
43
  expect(result).not.toBeNull();
50
44
  expect(result.preciseRoot).toBe(true);
51
- const find = (id) => result.descendants.find((d) => d.spanId === id);
52
- // Executor SERVER is exempt: it gets count 0 itself, and its descendants
53
- // don't see +1 from it.
54
- expect(find('execSrv').serverAncestorCount).toBe(0);
55
- expect(find('http1').serverAncestorCount).toBe(0);
56
- // The downstream SERVER does count for its descendants.
57
- expect(find('downSrv').serverAncestorCount).toBe(0);
58
- expect(find('http2').serverAncestorCount).toBe(1);
45
+ const ids = result.descendants.map((d) => d.spanId).sort();
46
+ expect(ids).toEqual(['http1', 'http2', 'nestedDdb']);
59
47
  });
60
48
  });
61
49
  describe('fallback root (no inner span)', () => {
@@ -88,48 +76,16 @@ describe('extractActionSubtree', () => {
88
76
  expect(ids).toEqual(['http1', 'nestedDdb']);
89
77
  });
90
78
  });
91
- describe('orphan counting', () => {
92
- it('counts orphans inside the action span time range', () => {
93
- const spans = [
94
- s({
95
- spanId: 'consumer',
96
- parentSpanId: '',
97
- startNs: 1000n,
98
- endNs: 9000n,
99
- attrs: { 'contrail.event_workflow.action.identifier': IDENT },
100
- }),
101
- // Inside [1000, 9000] with missing parent → counts.
102
- s({
103
- spanId: 'innerOrphan',
104
- parentSpanId: 'missing',
105
- startNs: 5000n,
106
- endNs: 6000n,
107
- }),
108
- ];
109
- const result = (0, subtree_1.extractActionSubtree)(spans, IDENT);
110
- expect(result.orphanCount).toBe(1);
111
- });
112
- it('ignores orphans whose time falls outside the action span', () => {
113
- const spans = [
114
- s({
115
- spanId: 'consumer',
116
- parentSpanId: '',
117
- startNs: 1000n,
118
- endNs: 9000n,
119
- attrs: { 'contrail.event_workflow.action.identifier': IDENT },
120
- }),
121
- // Sibling-of-action lineage span. Real parent is unobserved (window
122
- // edge effect) but its time is outside the action's range — noise,
123
- // not a real propagation gap.
124
- s({
125
- spanId: 'unrelated',
126
- parentSpanId: 'missing',
127
- startNs: 50000n,
128
- endNs: 60000n,
129
- }),
130
- ];
131
- const result = (0, subtree_1.extractActionSubtree)(spans, IDENT);
132
- expect(result.orphanCount).toBe(0);
133
- });
79
+ it('counts orphans (parent_id pointing outside the trace)', () => {
80
+ const spans = [
81
+ s({
82
+ spanId: 'consumer',
83
+ parentSpanId: '',
84
+ attrs: { 'contrail.event_workflow.action.identifier': IDENT },
85
+ }),
86
+ s({ spanId: 'unattached', parentSpanId: 'missing-parent' }),
87
+ ];
88
+ const result = (0, subtree_1.extractActionSubtree)(spans, IDENT);
89
+ expect(result.orphanCount).toBe(1);
134
90
  });
135
91
  });
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.5.0-alpha.11f30e6",
2
+ "version": "3.5.0-alpha.7efeaa8",
3
3
  "commands": {
4
4
  "app:create": {
5
5
  "id": "app:create",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/cli",
3
- "version": "3.5.0-alpha.11f30e6",
3
+ "version": "3.5.0-alpha.7efeaa8",
4
4
  "description": "VibeIQ's Contrail Platform CLI.",
5
5
  "bin": {
6
6
  "contrail-cli": "./bin/run",