@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 +33 -1
- package/dist/api/dash0/client.d.ts +13 -0
- package/dist/api/dash0/client.js +53 -0
- package/dist/api/dash0/spans.d.ts +8 -0
- package/dist/api/dash0/spans.js +91 -0
- package/dist/api/dash0/types.d.ts +84 -0
- package/dist/api/dash0/types.js +4 -0
- package/dist/commands/event-workflows/assess-impact.d.ts +19 -0
- package/dist/commands/event-workflows/assess-impact.js +104 -0
- package/dist/helpers/event-workflow-impact/aggregate.d.ts +23 -0
- package/dist/helpers/event-workflow-impact/aggregate.js +49 -0
- package/dist/helpers/event-workflow-impact/aggregate.spec.d.ts +1 -0
- package/dist/helpers/event-workflow-impact/aggregate.spec.js +109 -0
- package/dist/helpers/event-workflow-impact/classify.d.ts +13 -0
- package/dist/helpers/event-workflow-impact/classify.js +135 -0
- package/dist/helpers/event-workflow-impact/classify.spec.d.ts +1 -0
- package/dist/helpers/event-workflow-impact/classify.spec.js +138 -0
- package/dist/helpers/event-workflow-impact/render.d.ts +12 -0
- package/dist/helpers/event-workflow-impact/render.js +62 -0
- package/dist/helpers/event-workflow-impact/subtree.d.ts +20 -0
- package/dist/helpers/event-workflow-impact/subtree.js +93 -0
- package/dist/helpers/event-workflow-impact/subtree.spec.d.ts +1 -0
- package/dist/helpers/event-workflow-impact/subtree.spec.js +103 -0
- package/oclif.manifest.json +79 -1
- package/package.json +4 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeRouteName = exports.looksLikeId = exports.templatePath = exports.classifySpan = exports.CATEGORY_LABEL = exports.CATEGORY_ORDER = void 0;
|
|
4
|
+
exports.CATEGORY_ORDER = [
|
|
5
|
+
'http_direct',
|
|
6
|
+
'http_transitive',
|
|
7
|
+
'opensearch',
|
|
8
|
+
'dynamodb',
|
|
9
|
+
's3',
|
|
10
|
+
'timestream',
|
|
11
|
+
'lambda',
|
|
12
|
+
'other',
|
|
13
|
+
];
|
|
14
|
+
exports.CATEGORY_LABEL = {
|
|
15
|
+
http_direct: 'HTTP — direct (called by the action)',
|
|
16
|
+
http_transitive: 'HTTP — transitive (called by downstream services)',
|
|
17
|
+
opensearch: 'OpenSearch',
|
|
18
|
+
dynamodb: 'DynamoDB',
|
|
19
|
+
s3: 'S3',
|
|
20
|
+
timestream: 'Timestream',
|
|
21
|
+
lambda: 'Lambda invokes',
|
|
22
|
+
other: 'Other',
|
|
23
|
+
};
|
|
24
|
+
function classifySpan(span) {
|
|
25
|
+
const name = span.name;
|
|
26
|
+
const service = span.resourceAttrs['service.name'] ?? '';
|
|
27
|
+
const httpTarget = span.attrs['http.target'] ?? '';
|
|
28
|
+
// Skip SERVER spans entirely — they are the receive-side of an HTTP call we
|
|
29
|
+
// already counted on the client side, plus their internal DB ops are children
|
|
30
|
+
// and will be classified on their own.
|
|
31
|
+
if (span.kind === 2 /* SERVER */)
|
|
32
|
+
return null;
|
|
33
|
+
// OpenSearch HTTP request — match the actual /<index>/_search call,
|
|
34
|
+
// not the wrapping contrail-open-search lambda span.
|
|
35
|
+
if (httpTarget && /\/_search(\?|$)/.test(httpTarget)) {
|
|
36
|
+
const method = span.attrs['http.request.method'] ?? 'GET';
|
|
37
|
+
return {
|
|
38
|
+
category: 'opensearch',
|
|
39
|
+
service: 'opensearch',
|
|
40
|
+
operation: `${method} ${templatePath(httpTarget)}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// AWS SDK spans expose service via span name prefixes like `DynamoDB.Query`,
|
|
44
|
+
// `S3.PutObject`, `TimestreamWrite.WriteRecords`.
|
|
45
|
+
if (name.startsWith('DynamoDB.')) {
|
|
46
|
+
return { category: 'dynamodb', service: 'dynamodb', operation: name };
|
|
47
|
+
}
|
|
48
|
+
if (name.startsWith('S3.')) {
|
|
49
|
+
return { category: 's3', service: 's3', operation: name };
|
|
50
|
+
}
|
|
51
|
+
if (name.startsWith('Timestream')) {
|
|
52
|
+
return { category: 'timestream', service: 'timestream', operation: name };
|
|
53
|
+
}
|
|
54
|
+
const isHttp = span.attrs['dash0.span.type'] === 'http' ||
|
|
55
|
+
span.attrs['http.request.method'] !== undefined ||
|
|
56
|
+
span.attrs['http.host'] !== undefined;
|
|
57
|
+
if (isHttp) {
|
|
58
|
+
const method = (span.attrs['http.request.method'] ?? 'GET').toUpperCase();
|
|
59
|
+
const target = httpTarget || span.attrs['url.full'] || '';
|
|
60
|
+
const path = templatePath(target);
|
|
61
|
+
// Span name is sometimes just the method (e.g. "GET"), sometimes the full
|
|
62
|
+
// route (e.g. "get /prod/api/items"). Build the operation from method+path
|
|
63
|
+
// when we have a path; otherwise normalize the leading verb in name so
|
|
64
|
+
// "get /x" and "GET /x" don't split into two buckets.
|
|
65
|
+
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
|
+
return {
|
|
72
|
+
category: serverAncestors === 0 ? 'http_direct' : 'http_transitive',
|
|
73
|
+
service: service || 'unknown',
|
|
74
|
+
operation,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Lambda-to-lambda invokes appear as CLIENT spans on aws.lambda resources
|
|
78
|
+
// without HTTP attributes.
|
|
79
|
+
const isLambdaCall = span.kind === 3 /* CLIENT */ &&
|
|
80
|
+
span.resourceAttrs['dash0.resource.type'] === 'aws.lambda';
|
|
81
|
+
if (isLambdaCall) {
|
|
82
|
+
return { category: 'lambda', service: service || 'unknown', operation: name };
|
|
83
|
+
}
|
|
84
|
+
return { category: 'other', service: service || 'unknown', operation: name };
|
|
85
|
+
}
|
|
86
|
+
exports.classifySpan = classifySpan;
|
|
87
|
+
const HTTP_METHOD_PREFIX = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/i;
|
|
88
|
+
function templatePath(target) {
|
|
89
|
+
if (!target)
|
|
90
|
+
return '';
|
|
91
|
+
// Strip query string.
|
|
92
|
+
let path = target.split('?', 1)[0];
|
|
93
|
+
// Drop scheme+host if present.
|
|
94
|
+
if (path.startsWith('http')) {
|
|
95
|
+
try {
|
|
96
|
+
path = new URL(path).pathname;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* ignore */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return path
|
|
103
|
+
.split('/')
|
|
104
|
+
.map((seg) => (looksLikeId(seg) ? ':id' : seg))
|
|
105
|
+
.join('/');
|
|
106
|
+
}
|
|
107
|
+
exports.templatePath = templatePath;
|
|
108
|
+
// A segment is treated as an opaque ID (and replaced with `:id`) only when it
|
|
109
|
+
// looks like one. Long kebab-case route names like `assortment-items` should
|
|
110
|
+
// stay readable, so we require numeric content or non-trivial mixed case.
|
|
111
|
+
function looksLikeId(seg) {
|
|
112
|
+
if (seg.length === 0)
|
|
113
|
+
return false;
|
|
114
|
+
if (/^\d+$/.test(seg))
|
|
115
|
+
return true; // pure numeric
|
|
116
|
+
if (/^[0-9a-f]{32}$/i.test(seg))
|
|
117
|
+
return true; // 32-hex (trace IDs etc)
|
|
118
|
+
// UUIDs.
|
|
119
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
// Mixed case + length suggests an opaque ID (e.g. "cl3MF7mosBf8-Yf9").
|
|
123
|
+
// Pure-lowercase or pure-uppercase with hyphens stays a route name.
|
|
124
|
+
if (seg.length >= 12 && /[A-Z]/.test(seg) && /[a-z]/.test(seg))
|
|
125
|
+
return true;
|
|
126
|
+
// Long alphanumeric without separators is suspicious.
|
|
127
|
+
if (seg.length >= 16 && /^[A-Za-z0-9]+$/.test(seg) && /\d/.test(seg))
|
|
128
|
+
return true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
exports.looksLikeId = looksLikeId;
|
|
132
|
+
function normalizeRouteName(name) {
|
|
133
|
+
return name.replace(HTTP_METHOD_PREFIX, (m) => m.toUpperCase());
|
|
134
|
+
}
|
|
135
|
+
exports.normalizeRouteName = normalizeRouteName;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const classify_1 = require("./classify");
|
|
4
|
+
function span(partial = {}) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'unnamed',
|
|
7
|
+
traceId: 't',
|
|
8
|
+
spanId: 's',
|
|
9
|
+
parentSpanId: '',
|
|
10
|
+
kind: 3,
|
|
11
|
+
startNs: 0n,
|
|
12
|
+
endNs: 0n,
|
|
13
|
+
durationNs: 0n,
|
|
14
|
+
attrs: {},
|
|
15
|
+
resourceAttrs: {},
|
|
16
|
+
...partial,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe('templatePath', () => {
|
|
20
|
+
it('strips query string', () => {
|
|
21
|
+
expect((0, classify_1.templatePath)('/api/items?x=1')).toBe('/api/items');
|
|
22
|
+
});
|
|
23
|
+
it('replaces id-shaped segments with :id', () => {
|
|
24
|
+
expect((0, classify_1.templatePath)('/api/items/cl3MF7mosBf8-Yf9')).toBe('/api/items/:id');
|
|
25
|
+
expect((0, classify_1.templatePath)('/api/items/123456')).toBe('/api/items/:id');
|
|
26
|
+
});
|
|
27
|
+
it('keeps short alphabetic segments untouched', () => {
|
|
28
|
+
expect((0, classify_1.templatePath)('/api/items/foo')).toBe('/api/items/foo');
|
|
29
|
+
});
|
|
30
|
+
it('keeps long kebab-case route names untouched', () => {
|
|
31
|
+
expect((0, classify_1.templatePath)('/prod/api/assortment-items/batch')).toBe('/prod/api/assortment-items/batch');
|
|
32
|
+
expect((0, classify_1.templatePath)('/prod/api/project-items')).toBe('/prod/api/project-items');
|
|
33
|
+
});
|
|
34
|
+
it('replaces UUID segments', () => {
|
|
35
|
+
expect((0, classify_1.templatePath)('/api/things/550e8400-e29b-41d4-a716-446655440000')).toBe('/api/things/:id');
|
|
36
|
+
});
|
|
37
|
+
it('handles full URLs', () => {
|
|
38
|
+
expect((0, classify_1.templatePath)('https://api.vibeiq.com/prod/api/items?x=1')).toBe('/prod/api/items');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('classifySpan', () => {
|
|
42
|
+
it('classifies OpenSearch /_search HTTP spans', () => {
|
|
43
|
+
const s = span({
|
|
44
|
+
name: 'GET',
|
|
45
|
+
attrs: {
|
|
46
|
+
'http.target': '/gap1/_search',
|
|
47
|
+
'http.request.method': 'GET',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect((0, classify_1.classifySpan)(s)).toEqual({
|
|
51
|
+
category: 'opensearch',
|
|
52
|
+
service: 'opensearch',
|
|
53
|
+
operation: 'GET /gap1/_search',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
it('classifies DynamoDB AWS SDK spans by name prefix', () => {
|
|
57
|
+
expect((0, classify_1.classifySpan)(span({ name: 'DynamoDB.Query' }))).toEqual({
|
|
58
|
+
category: 'dynamodb',
|
|
59
|
+
service: 'dynamodb',
|
|
60
|
+
operation: 'DynamoDB.Query',
|
|
61
|
+
});
|
|
62
|
+
expect((0, classify_1.classifySpan)(span({ name: 'DynamoDB.UpdateItem' }))?.category).toBe('dynamodb');
|
|
63
|
+
});
|
|
64
|
+
it('classifies S3 AWS SDK spans', () => {
|
|
65
|
+
expect((0, classify_1.classifySpan)(span({ name: 'S3.PutObject' }))?.category).toBe('s3');
|
|
66
|
+
});
|
|
67
|
+
it('classifies Timestream AWS SDK spans', () => {
|
|
68
|
+
expect((0, classify_1.classifySpan)(span({ name: 'TimestreamWrite.WriteRecords' }))?.category).toBe('timestream');
|
|
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.
|
|
73
|
+
const s = span({
|
|
74
|
+
name: 'GET',
|
|
75
|
+
attrs: {
|
|
76
|
+
'http.request.method': 'GET',
|
|
77
|
+
'http.target': '/prod/api/items?relations=&criteria=...',
|
|
78
|
+
'http.host': 'api.vibeiq.com:443',
|
|
79
|
+
},
|
|
80
|
+
resourceAttrs: { 'service.name': 'contrail-event-workflow-apps' },
|
|
81
|
+
serverAncestorCount: 0,
|
|
82
|
+
});
|
|
83
|
+
expect((0, classify_1.classifySpan)(s)).toEqual({
|
|
84
|
+
category: 'http_direct',
|
|
85
|
+
service: 'contrail-event-workflow-apps',
|
|
86
|
+
operation: 'GET /prod/api/items',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
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
|
+
it('classifies lambda-to-lambda invokes (no HTTP attrs, aws.lambda resource)', () => {
|
|
105
|
+
const s = span({
|
|
106
|
+
name: 'contrail-items-prod-api',
|
|
107
|
+
kind: 3,
|
|
108
|
+
resourceAttrs: {
|
|
109
|
+
'service.name': 'contrail-items',
|
|
110
|
+
'dash0.resource.type': 'aws.lambda',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
expect((0, classify_1.classifySpan)(s)).toEqual({
|
|
114
|
+
category: 'lambda',
|
|
115
|
+
service: 'contrail-items',
|
|
116
|
+
operation: 'contrail-items-prod-api',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
it('drops SERVER-side spans (already represented by client side)', () => {
|
|
120
|
+
expect((0, classify_1.classifySpan)(span({ kind: 2, name: 'GET /prod/api/items' }))).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
it('uppercases the HTTP method when falling back to span.name', () => {
|
|
123
|
+
// Some spans have only http.host set (not http.target/method) and a
|
|
124
|
+
// lowercase verb in the span name. They should bucket with their uppercase
|
|
125
|
+
// siblings rather than splitting.
|
|
126
|
+
const lower = span({
|
|
127
|
+
name: 'get /prod/api/custom-entities',
|
|
128
|
+
attrs: { 'http.host': 'api.vibeiq.com:443' },
|
|
129
|
+
resourceAttrs: { 'service.name': 'contrail-custom-entities' },
|
|
130
|
+
serverAncestorCount: 1,
|
|
131
|
+
});
|
|
132
|
+
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
|
+
});
|
|
135
|
+
it('falls back to "other" for unknown CLIENT spans without aws.lambda resource', () => {
|
|
136
|
+
expect((0, classify_1.classifySpan)(span({ name: 'mystery-step' }))?.category).toBe('other');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AggregateResult } from './aggregate';
|
|
2
|
+
export interface RenderOptions {
|
|
3
|
+
identifier: string;
|
|
4
|
+
dataset: string;
|
|
5
|
+
lookback: string;
|
|
6
|
+
samplesRequested: number;
|
|
7
|
+
samplesObtained: number;
|
|
8
|
+
tracesDropped: number;
|
|
9
|
+
totalOrphanCount: number;
|
|
10
|
+
preciseRootCount: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function renderReport(result: AggregateResult, opts: RenderOptions): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderReport = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const cli_table3_1 = tslib_1.__importDefault(require("cli-table3"));
|
|
6
|
+
const classify_1 = require("./classify");
|
|
7
|
+
function renderReport(result, opts) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
lines.push(`Action: ${opts.identifier}`);
|
|
10
|
+
lines.push(`Dataset: ${opts.dataset}`);
|
|
11
|
+
lines.push(`Lookback: ${opts.lookback}`);
|
|
12
|
+
lines.push(`Samples: ${opts.samplesObtained}/${opts.samplesRequested} (${opts.tracesDropped} dropped)`);
|
|
13
|
+
if (opts.preciseRootCount < opts.samplesObtained) {
|
|
14
|
+
const fallback = opts.samplesObtained - opts.preciseRootCount;
|
|
15
|
+
lines.push(`Rooting: ${opts.preciseRootCount} precise / ${fallback} fallback (no inner action span — framework AWS-SDK direct children stripped heuristically)`);
|
|
16
|
+
}
|
|
17
|
+
lines.push('');
|
|
18
|
+
lines.push('Per-invocation downstream calls. "% of traces" is how often the bucket appears in any invocation; median/p95/max include zeros.');
|
|
19
|
+
lines.push('');
|
|
20
|
+
const byCategory = new Map();
|
|
21
|
+
for (const b of result.buckets) {
|
|
22
|
+
const arr = byCategory.get(b.key.category) ?? [];
|
|
23
|
+
arr.push(b);
|
|
24
|
+
byCategory.set(b.key.category, arr);
|
|
25
|
+
}
|
|
26
|
+
let any = false;
|
|
27
|
+
for (const cat of classify_1.CATEGORY_ORDER) {
|
|
28
|
+
const buckets = byCategory.get(cat);
|
|
29
|
+
if (!buckets || buckets.length === 0)
|
|
30
|
+
continue;
|
|
31
|
+
any = true;
|
|
32
|
+
lines.push(classify_1.CATEGORY_LABEL[cat]);
|
|
33
|
+
const table = new cli_table3_1.default({
|
|
34
|
+
head: ['service', 'operation', 'median', 'p95', 'max', '% of traces'],
|
|
35
|
+
style: { head: [], border: [] },
|
|
36
|
+
});
|
|
37
|
+
buckets
|
|
38
|
+
.sort((a, b) => b.p95 - a.p95 || b.max - a.max)
|
|
39
|
+
.forEach((b) => table.push([
|
|
40
|
+
b.key.service,
|
|
41
|
+
b.key.operation,
|
|
42
|
+
fmt(b.median),
|
|
43
|
+
fmt(b.p95),
|
|
44
|
+
fmt(b.max),
|
|
45
|
+
`${(b.coverage * 100).toFixed(0)}%`,
|
|
46
|
+
]));
|
|
47
|
+
lines.push(table.toString());
|
|
48
|
+
lines.push('');
|
|
49
|
+
}
|
|
50
|
+
if (!any) {
|
|
51
|
+
lines.push(' (no descendant spans observed in the action subtree)');
|
|
52
|
+
lines.push('');
|
|
53
|
+
}
|
|
54
|
+
if (opts.totalOrphanCount > 0) {
|
|
55
|
+
lines.push(`Note: ${opts.totalOrphanCount} span(s) across sampled traces have a parent ID we never observed — likely propagation gaps. Numbers above may undercount.`);
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
exports.renderReport = renderReport;
|
|
60
|
+
function fmt(n) {
|
|
61
|
+
return Number.isInteger(n) ? n.toString() : n.toFixed(1);
|
|
62
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FlatSpan } from '../../api/dash0/types';
|
|
2
|
+
export interface SubtreeResult {
|
|
3
|
+
descendants: FlatSpan[];
|
|
4
|
+
actionSpan: FlatSpan;
|
|
5
|
+
preciseRoot: boolean;
|
|
6
|
+
orphanCount: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Walk descendants under the span carrying the action identifier.
|
|
10
|
+
*
|
|
11
|
+
* The action identifier is set on the workflow consumer span (the lambda root)
|
|
12
|
+
* AND echoed onto a child PRODUCER span named after the action identifier itself.
|
|
13
|
+
* We root from whichever bears `contrail.event_workflow.action.identifier` AND has
|
|
14
|
+
* a span name matching the identifier (the inner action span). If we can't find
|
|
15
|
+
* that, we fall back to the consumer-level span.
|
|
16
|
+
*
|
|
17
|
+
* The returned descendants set excludes the framework consumer overhead — only
|
|
18
|
+
* what the action's code touched.
|
|
19
|
+
*/
|
|
20
|
+
export declare function extractActionSubtree(spans: FlatSpan[], identifier: string): SubtreeResult | null;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractActionSubtree = void 0;
|
|
4
|
+
// AWS SDK span name prefixes that, when emitted as a *direct* child of the
|
|
5
|
+
// consumer span, represent the workflow framework's own infrastructure
|
|
6
|
+
// (task-state DDB writes, S3 payload staging, Timestream metrics, queue ops),
|
|
7
|
+
// not customer code. These are stripped (along with their subtrees) when we
|
|
8
|
+
// have to fall back to the consumer span as the root.
|
|
9
|
+
const FRAMEWORK_AWS_SDK_PREFIXES = ['DynamoDB.', 'S3.', 'Timestream', 'SQS.', 'SNS.'];
|
|
10
|
+
function isFrameworkAwsSdk(span) {
|
|
11
|
+
return FRAMEWORK_AWS_SDK_PREFIXES.some((p) => span.name.startsWith(p));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Walk descendants under the span carrying the action identifier.
|
|
15
|
+
*
|
|
16
|
+
* The action identifier is set on the workflow consumer span (the lambda root)
|
|
17
|
+
* AND echoed onto a child PRODUCER span named after the action identifier itself.
|
|
18
|
+
* We root from whichever bears `contrail.event_workflow.action.identifier` AND has
|
|
19
|
+
* a span name matching the identifier (the inner action span). If we can't find
|
|
20
|
+
* that, we fall back to the consumer-level span.
|
|
21
|
+
*
|
|
22
|
+
* The returned descendants set excludes the framework consumer overhead — only
|
|
23
|
+
* what the action's code touched.
|
|
24
|
+
*/
|
|
25
|
+
function extractActionSubtree(spans, identifier) {
|
|
26
|
+
if (spans.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const byParent = new Map();
|
|
29
|
+
const byId = new Map();
|
|
30
|
+
for (const s of spans) {
|
|
31
|
+
byId.set(s.spanId, s);
|
|
32
|
+
if (s.parentSpanId) {
|
|
33
|
+
const arr = byParent.get(s.parentSpanId) ?? [];
|
|
34
|
+
arr.push(s);
|
|
35
|
+
byParent.set(s.parentSpanId, arr);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// The inner action span is named exactly after the identifier (PRODUCER kind,
|
|
39
|
+
// child of the consumer lambda). When that exists, walking it yields exactly
|
|
40
|
+
// the action's downstream calls.
|
|
41
|
+
const inner = spans.find((s) => s.name === identifier);
|
|
42
|
+
// The consumer/root span carries `contrail.event_workflow.action.identifier`
|
|
43
|
+
// as an attribute. Some action variants run inline in the consumer (no
|
|
44
|
+
// inner PRODUCER span); for those we root at the consumer and strip the
|
|
45
|
+
// framework's direct AWS-SDK children.
|
|
46
|
+
const carrier = spans.find((s) => s.attrs['contrail.event_workflow.action.identifier'] === identifier);
|
|
47
|
+
const actionSpan = inner ?? carrier;
|
|
48
|
+
if (!actionSpan)
|
|
49
|
+
return null;
|
|
50
|
+
const preciseRoot = !!inner;
|
|
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();
|
|
63
|
+
const children = byParent.get(parentId) ?? [];
|
|
64
|
+
for (const c of children) {
|
|
65
|
+
// Fallback path: when rooting at the consumer, AWS-SDK spans that are a
|
|
66
|
+
// *direct* child of the consumer are framework infrastructure (task-state
|
|
67
|
+
// DDB writes, S3 payload staging, Timestream metrics). Skip them and
|
|
68
|
+
// their subtrees. Nested AWS-SDK spans (e.g. DDB queries inside a
|
|
69
|
+
// downstream contrail-items lambda) are kept.
|
|
70
|
+
if (!preciseRoot && parentId === actionSpan.spanId && isFrameworkAwsSdk(c)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
c.depthFromAction = depth + 1;
|
|
74
|
+
c.serverAncestorCount = serverAncestors;
|
|
75
|
+
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 });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let orphanCount = 0;
|
|
87
|
+
for (const s of spans) {
|
|
88
|
+
if (s.parentSpanId && !byId.has(s.parentSpanId))
|
|
89
|
+
orphanCount++;
|
|
90
|
+
}
|
|
91
|
+
return { descendants, actionSpan, preciseRoot, orphanCount };
|
|
92
|
+
}
|
|
93
|
+
exports.extractActionSubtree = extractActionSubtree;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const subtree_1 = require("./subtree");
|
|
4
|
+
const IDENT = '@gap-automations:exampleAction';
|
|
5
|
+
function s(part) {
|
|
6
|
+
return {
|
|
7
|
+
name: part.spanId,
|
|
8
|
+
traceId: 't',
|
|
9
|
+
parentSpanId: '',
|
|
10
|
+
kind: 3,
|
|
11
|
+
startNs: 0n,
|
|
12
|
+
endNs: 0n,
|
|
13
|
+
durationNs: 0n,
|
|
14
|
+
attrs: {},
|
|
15
|
+
resourceAttrs: {},
|
|
16
|
+
...part,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe('extractActionSubtree', () => {
|
|
20
|
+
it('returns null when no candidate exists', () => {
|
|
21
|
+
expect((0, subtree_1.extractActionSubtree)([s({ spanId: 'a' })], IDENT)).toBeNull();
|
|
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.
|
|
30
|
+
const spans = [
|
|
31
|
+
s({
|
|
32
|
+
spanId: 'consumer',
|
|
33
|
+
parentSpanId: '',
|
|
34
|
+
name: 'consumer',
|
|
35
|
+
kind: 2,
|
|
36
|
+
attrs: { 'contrail.event_workflow.action.identifier': IDENT },
|
|
37
|
+
}),
|
|
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 }),
|
|
47
|
+
];
|
|
48
|
+
const result = (0, subtree_1.extractActionSubtree)(spans, IDENT);
|
|
49
|
+
expect(result).not.toBeNull();
|
|
50
|
+
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);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('fallback root (no inner span)', () => {
|
|
62
|
+
it('walks consumer subtree but strips direct AWS-SDK children', () => {
|
|
63
|
+
// consumer carries the identifier attr.
|
|
64
|
+
// direct children: framework DDB, framework S3, framework Timestream,
|
|
65
|
+
// http GET (action work)
|
|
66
|
+
// http GET -> nested DDB (downstream's own DDB; should be kept)
|
|
67
|
+
const spans = [
|
|
68
|
+
s({
|
|
69
|
+
spanId: 'consumer',
|
|
70
|
+
parentSpanId: '',
|
|
71
|
+
name: 'consumer-lambda',
|
|
72
|
+
attrs: { 'contrail.event_workflow.action.identifier': IDENT },
|
|
73
|
+
}),
|
|
74
|
+
s({ spanId: 'fwDdb', parentSpanId: 'consumer', name: 'DynamoDB.Query' }),
|
|
75
|
+
s({ spanId: 'fwS3', parentSpanId: 'consumer', name: 'S3.GetObject' }),
|
|
76
|
+
s({
|
|
77
|
+
spanId: 'fwTs',
|
|
78
|
+
parentSpanId: 'consumer',
|
|
79
|
+
name: 'TimestreamWrite.WriteRecords',
|
|
80
|
+
}),
|
|
81
|
+
s({ spanId: 'http1', parentSpanId: 'consumer', name: 'GET' }),
|
|
82
|
+
s({ spanId: 'nestedDdb', parentSpanId: 'http1', name: 'DynamoDB.Query' }),
|
|
83
|
+
];
|
|
84
|
+
const result = (0, subtree_1.extractActionSubtree)(spans, IDENT);
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result.preciseRoot).toBe(false);
|
|
87
|
+
const ids = result.descendants.map((d) => d.spanId).sort();
|
|
88
|
+
expect(ids).toEqual(['http1', 'nestedDdb']);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
it('counts orphans (parent_id pointing outside the trace)', () => {
|
|
92
|
+
const spans = [
|
|
93
|
+
s({
|
|
94
|
+
spanId: 'consumer',
|
|
95
|
+
parentSpanId: '',
|
|
96
|
+
attrs: { 'contrail.event_workflow.action.identifier': IDENT },
|
|
97
|
+
}),
|
|
98
|
+
s({ spanId: 'unattached', parentSpanId: 'missing-parent' }),
|
|
99
|
+
];
|
|
100
|
+
const result = (0, subtree_1.extractActionSubtree)(spans, IDENT);
|
|
101
|
+
expect(result.orphanCount).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/oclif.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.5.0-alpha.
|
|
2
|
+
"version": "3.5.0-alpha.dd20a19",
|
|
3
3
|
"commands": {
|
|
4
4
|
"app:create": {
|
|
5
5
|
"id": "app:create",
|
|
@@ -672,6 +672,84 @@
|
|
|
672
672
|
}
|
|
673
673
|
}
|
|
674
674
|
},
|
|
675
|
+
"event-workflows:assess-impact": {
|
|
676
|
+
"id": "event-workflows:assess-impact",
|
|
677
|
+
"description": "Sample Dash0 traces of an event-workflow action and report per-invocation downstream impact.",
|
|
678
|
+
"strict": true,
|
|
679
|
+
"pluginName": "@contrail/cli",
|
|
680
|
+
"pluginAlias": "@contrail/cli",
|
|
681
|
+
"pluginType": "core",
|
|
682
|
+
"aliases": [],
|
|
683
|
+
"examples": [
|
|
684
|
+
"contrail event-workflows assess-impact @gap-automations:bomCcCostUpdate",
|
|
685
|
+
"contrail event-workflows assess-impact @gap-automations:bomCcCostUpdate --samples 25 --lookback 6h"
|
|
686
|
+
],
|
|
687
|
+
"flags": {
|
|
688
|
+
"logLevel": {
|
|
689
|
+
"name": "logLevel",
|
|
690
|
+
"type": "option",
|
|
691
|
+
"char": "l",
|
|
692
|
+
"description": "The Log Level",
|
|
693
|
+
"required": false,
|
|
694
|
+
"multiple": false,
|
|
695
|
+
"options": [
|
|
696
|
+
"error",
|
|
697
|
+
"warn",
|
|
698
|
+
"info",
|
|
699
|
+
"debug"
|
|
700
|
+
]
|
|
701
|
+
},
|
|
702
|
+
"alwaysConfirmEnv": {
|
|
703
|
+
"name": "alwaysConfirmEnv",
|
|
704
|
+
"type": "option",
|
|
705
|
+
"char": "e",
|
|
706
|
+
"description": "The Environment variables",
|
|
707
|
+
"required": false,
|
|
708
|
+
"multiple": false,
|
|
709
|
+
"options": [
|
|
710
|
+
"true",
|
|
711
|
+
"false"
|
|
712
|
+
],
|
|
713
|
+
"default": "true"
|
|
714
|
+
},
|
|
715
|
+
"samples": {
|
|
716
|
+
"name": "samples",
|
|
717
|
+
"type": "option",
|
|
718
|
+
"char": "s",
|
|
719
|
+
"description": "Number of distinct traces to sample (10-100).",
|
|
720
|
+
"multiple": false,
|
|
721
|
+
"default": 50
|
|
722
|
+
},
|
|
723
|
+
"lookback": {
|
|
724
|
+
"name": "lookback",
|
|
725
|
+
"type": "option",
|
|
726
|
+
"description": "How far back to search. Format: 30m | 6h | 2d.",
|
|
727
|
+
"multiple": false,
|
|
728
|
+
"default": "2d"
|
|
729
|
+
},
|
|
730
|
+
"dataset": {
|
|
731
|
+
"name": "dataset",
|
|
732
|
+
"type": "option",
|
|
733
|
+
"description": "Dash0 dataset.",
|
|
734
|
+
"multiple": false,
|
|
735
|
+
"default": "prod"
|
|
736
|
+
},
|
|
737
|
+
"json": {
|
|
738
|
+
"name": "json",
|
|
739
|
+
"type": "boolean",
|
|
740
|
+
"char": "j",
|
|
741
|
+
"description": "JSON output (not yet implemented; flag reserved).",
|
|
742
|
+
"allowNo": false
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
"args": {
|
|
746
|
+
"identifier": {
|
|
747
|
+
"name": "identifier",
|
|
748
|
+
"description": "Action identifier (contrail.event_workflow.action.identifier).",
|
|
749
|
+
"required": true
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
},
|
|
675
753
|
"file:upload": {
|
|
676
754
|
"id": "file:upload",
|
|
677
755
|
"description": "Uploads a file to the VibeIQ platform",
|