@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 +1 -1
- package/dist/api/dash0/spans.d.ts +1 -6
- package/dist/api/dash0/spans.js +6 -24
- package/dist/api/dash0/types.d.ts +0 -2
- package/dist/commands/event-workflows/assess-impact.js +5 -12
- package/dist/helpers/event-workflow-impact/aggregate.d.ts +0 -9
- package/dist/helpers/event-workflow-impact/aggregate.js +1 -17
- package/dist/helpers/event-workflow-impact/aggregate.spec.js +1 -27
- package/dist/helpers/event-workflow-impact/classify.d.ts +1 -1
- package/dist/helpers/event-workflow-impact/classify.js +3 -10
- package/dist/helpers/event-workflow-impact/classify.spec.js +2 -22
- package/dist/helpers/event-workflow-impact/render.d.ts +1 -2
- package/dist/helpers/event-workflow-impact/render.js +2 -17
- package/dist/helpers/event-workflow-impact/subtree.js +6 -30
- package/dist/helpers/event-workflow-impact/subtree.spec.js +24 -68
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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>;
|
package/dist/api/dash0/spans.js
CHANGED
|
@@ -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.
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
return [...seen];
|
|
42
37
|
}
|
|
43
38
|
}
|
|
44
39
|
}
|
|
45
|
-
return [...seen
|
|
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)) {
|
|
@@ -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
|
|
64
|
-
if (
|
|
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 ${
|
|
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
|
|
76
|
-
|
|
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.
|
|
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 === '
|
|
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 = '
|
|
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
|
-
'
|
|
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
|
-
|
|
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:
|
|
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
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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', '
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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 (
|
|
93
|
-
|
|
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
|
|
24
|
-
it('
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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: '
|
|
39
|
-
|
|
40
|
-
s({ spanId: '
|
|
41
|
-
|
|
42
|
-
s({ spanId: '
|
|
43
|
-
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
});
|
package/oclif.manifest.json
CHANGED