canary-test-cli 7.0.0 → 7.2.0
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/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- package/package.json +3 -2
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// canary-instrument -- correlate a Playwright run's tests to their outbound
|
|
3
|
+
// HTTP spans. Ported from cli.py, behavior-preserving.
|
|
4
|
+
//
|
|
5
|
+
// Reads OTel span JSONL files written by otel_bootstrap/instrument.mjs (see
|
|
6
|
+
// SKILL.md for the two manual wiring steps), resolves each Playwright test's
|
|
7
|
+
// root span (set by otel_bootstrap/playwright-fixture.ts), attaches HTTP child
|
|
8
|
+
// spans, and writes a run.json v1 artifact (trace-only; see run_types.mjs for
|
|
9
|
+
// the contract).
|
|
10
|
+
//
|
|
11
|
+
// Invoked via:
|
|
12
|
+
// canary skills run canary-instrument -- \
|
|
13
|
+
// --spans test-results/trace --output test-results [--suite-type e2e_ui]
|
|
14
|
+
//
|
|
15
|
+
// Missing/empty --spans is not a failure -- it produces an empty trace block.
|
|
16
|
+
// Self-contained -- no external skill dependency.
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import {
|
|
21
|
+
createParser,
|
|
22
|
+
formatUsageError,
|
|
23
|
+
EXIT_USAGE,
|
|
24
|
+
} from '../../../lib/parse-args.mjs';
|
|
25
|
+
|
|
26
|
+
import { RunArtifact } from './run_types.mjs';
|
|
27
|
+
import { readTraces } from './span_reader.mjs';
|
|
28
|
+
|
|
29
|
+
const USAGE =
|
|
30
|
+
'usage: canary-instrument [-h] --spans PATH --output PATH\n' +
|
|
31
|
+
' [--suite-type TYPE]\n' +
|
|
32
|
+
'\n' +
|
|
33
|
+
"Correlate a Playwright run's tests to their outbound HTTP spans and write a\n" +
|
|
34
|
+
'run.json v1 artifact.\n' +
|
|
35
|
+
'\n' +
|
|
36
|
+
'options:\n' +
|
|
37
|
+
' -h, --help show this help message and exit\n' +
|
|
38
|
+
' --spans PATH directory of OTel span JSONL files to read\n' +
|
|
39
|
+
' --output PATH directory to write run.json into (created if missing)\n' +
|
|
40
|
+
' --suite-type TYPE suite label recorded in the artifact (e.g. e2e_ui)';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Both value flags are required, and `--suite-type` is a free-form label. Two
|
|
44
|
+
* deliberate divergences from argparse survive the move to the shared parser
|
|
45
|
+
* (#479): flags must be spelled in full (no prefix abbreviation, so `--out` is
|
|
46
|
+
* an unknown flag rather than `--output`), and an empty value is now REJECTED
|
|
47
|
+
* rather than passed through -- `--spans=` used to yield an empty trace, which
|
|
48
|
+
* is the missing-value case wearing a disguise.
|
|
49
|
+
*/
|
|
50
|
+
export const CLI_SPEC = {
|
|
51
|
+
prog: 'canary-instrument',
|
|
52
|
+
values: {
|
|
53
|
+
'--spans': { key: 'spans' },
|
|
54
|
+
'--output': { key: 'output' },
|
|
55
|
+
'--suite-type': { key: 'suiteType' },
|
|
56
|
+
},
|
|
57
|
+
defaults: { suiteType: '' },
|
|
58
|
+
required: ['--spans', '--output'],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const parseArgs = createParser(CLI_SPEC);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serialize like Python's json.dumps(obj, indent=2): 2-space indent AND
|
|
65
|
+
* ensure_ascii=True. JSON.stringify already matches the indentation and empty
|
|
66
|
+
* container ("[]"/"{}") forms, but leaves non-ASCII raw -- so escape every code
|
|
67
|
+
* unit above 0x7F to a \uXXXX sequence (astral chars become their two UTF-16
|
|
68
|
+
* surrogate escapes, exactly as Python emits them). No trailing newline,
|
|
69
|
+
* matching cli.py's write_text.
|
|
70
|
+
*/
|
|
71
|
+
function dumpsIndent2Ascii(obj) {
|
|
72
|
+
const nonAscii = /[\u0080-\uffff]/g;
|
|
73
|
+
return JSON.stringify(obj, null, 2).replace(
|
|
74
|
+
nonAscii,
|
|
75
|
+
(ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Python's datetime.now(timezone.utc).isoformat() renders a "+00:00" offset;
|
|
81
|
+
* JS toISOString() renders "Z". Normalize the suffix so generated_at keeps the
|
|
82
|
+
* documented "+00:00" convention. (Python emits microseconds, JS milliseconds;
|
|
83
|
+
* generated_at is a wall-clock stamp and is never asserted against.)
|
|
84
|
+
*/
|
|
85
|
+
function nowIso() {
|
|
86
|
+
return new Date().toISOString().replace('Z', '+00:00');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function main(argv = []) {
|
|
90
|
+
const { opts: args, help, error } = parseArgs(argv);
|
|
91
|
+
|
|
92
|
+
if (help) {
|
|
93
|
+
console.log(USAGE);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
if (error) {
|
|
97
|
+
console.error(formatUsageError(CLI_SPEC.prog, error));
|
|
98
|
+
return EXIT_USAGE;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const spansDir = args.spans;
|
|
102
|
+
if (existsButNotDir(spansDir)) {
|
|
103
|
+
console.error(`canary-instrument: --spans is not a directory: ${spansDir}`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const trace = readTraces(spansDir);
|
|
108
|
+
const artifact = RunArtifact({
|
|
109
|
+
schema_version: 1,
|
|
110
|
+
suite_type: args.suiteType,
|
|
111
|
+
generated_at: nowIso(),
|
|
112
|
+
trace,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// A run.json whose requests carry no URL is worse than no run.json. The
|
|
116
|
+
// artifact's whole purpose is answering "which test hit which endpoint", so a
|
|
117
|
+
// consumer that reads URL-less requests computes zero endpoints matched and
|
|
118
|
+
// renders a clean 0% as though it were a measurement. Refuse to write it.
|
|
119
|
+
//
|
|
120
|
+
// Note this is NOT the empty-spans case, which is deliberately fine (see the
|
|
121
|
+
// header): zero spans yields an empty trace and exit 0, and "0 spans, 0
|
|
122
|
+
// buckets" describes itself honestly. What fails here is the drift signature --
|
|
123
|
+
// spans WERE captured and correlated, and every one of them lost its URL,
|
|
124
|
+
// which is what an attribute-name mismatch looks like from the outside.
|
|
125
|
+
const requests = trace.by_test.flatMap((t) => t.requests ?? []);
|
|
126
|
+
const withUrl = requests.filter((r) => r.url);
|
|
127
|
+
if (requests.length > 0 && withUrl.length === 0) {
|
|
128
|
+
console.error(
|
|
129
|
+
`canary-instrument: correlated ${requests.length} request(s) across ` +
|
|
130
|
+
`${trace.spans_total} span(s) and NONE carried a URL. Refusing to write ` +
|
|
131
|
+
`an artifact that would read as "no endpoints exercised".`,
|
|
132
|
+
);
|
|
133
|
+
console.error(
|
|
134
|
+
'This is what an OTel semantic-convention mismatch looks like: the spans ' +
|
|
135
|
+
'are present and attributed, but the URL attribute this reader expects is ' +
|
|
136
|
+
'not the one the instrumentation emitted. Check the attribute keys on an ' +
|
|
137
|
+
'HTTP span in --spans against requestUrl() in span_reader.mjs.',
|
|
138
|
+
);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const outputDir = args.output;
|
|
143
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
144
|
+
const outPath = path.join(outputDir, 'run.json');
|
|
145
|
+
fs.writeFileSync(outPath, dumpsIndent2Ascii(artifact), 'utf8');
|
|
146
|
+
|
|
147
|
+
console.log(
|
|
148
|
+
`canary-instrument: wrote ${outPath} ` +
|
|
149
|
+
`(${trace.spans_total} spans, ${trace.by_test.length} test buckets, ` +
|
|
150
|
+
`${withUrl.length}/${requests.length} request(s) with a URL)`,
|
|
151
|
+
);
|
|
152
|
+
// Partial loss is advisory: a mixed run is still usable, but silence would let
|
|
153
|
+
// a growing blind spot pass for a shrinking one.
|
|
154
|
+
if (withUrl.length < requests.length) {
|
|
155
|
+
console.warn(
|
|
156
|
+
`canary-instrument: ${requests.length - withUrl.length} request(s) had no ` +
|
|
157
|
+
'resolvable URL and will not match any endpoint.',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function existsButNotDir(p) {
|
|
164
|
+
try {
|
|
165
|
+
return !fs.statSync(p).isDirectory();
|
|
166
|
+
} catch {
|
|
167
|
+
return false; // missing path is fine -- yields an empty trace
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Direct execution (the skill runner execs this file via its shebang).
|
|
172
|
+
//
|
|
173
|
+
// `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
|
|
174
|
+
// the pipe buffer, and `process.exit` tears the process down mid-write, leaving
|
|
175
|
+
// truncated JSON that still exits 0 (#791).
|
|
176
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
177
|
+
process.exitCode = main(process.argv.slice(2));
|
|
178
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// otel_bootstrap/instrument.mjs
|
|
2
|
+
//
|
|
3
|
+
// Node OTel SDK bootstrap for canary-instrument. Import via:
|
|
4
|
+
// NODE_OPTIONS="--import ./otel_bootstrap/instrument.mjs" npx playwright test
|
|
5
|
+
//
|
|
6
|
+
// Default: writes one JSON span per line to
|
|
7
|
+
// test-results/trace/otel-spans.<TEST_WORKER_INDEX>.jsonl
|
|
8
|
+
// (no collector required — this is what scripts/span_reader.mjs reads).
|
|
9
|
+
// When OTEL_EXPORTER_OTLP_ENDPOINT is set, spans are *additionally*
|
|
10
|
+
// streamed to that collector via OTLPTraceExporter; the file path above is
|
|
11
|
+
// unaffected either way.
|
|
12
|
+
//
|
|
13
|
+
// Auto-instruments HTTP/undici only — fs instrumentation is disabled so
|
|
14
|
+
// Playwright's own file I/O doesn't show up as noise spans.
|
|
15
|
+
//
|
|
16
|
+
// Consumer-supplied dependencies (not vendored by this skill):
|
|
17
|
+
// @opentelemetry/sdk-node @opentelemetry/api
|
|
18
|
+
// @opentelemetry/auto-instrumentations-node
|
|
19
|
+
// @opentelemetry/exporter-trace-otlp-http
|
|
20
|
+
|
|
21
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
22
|
+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
|
23
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
24
|
+
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
const workerIndex = process.env.TEST_WORKER_INDEX ?? '0';
|
|
29
|
+
const outDir = path.join(process.cwd(), 'test-results', 'trace');
|
|
30
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
31
|
+
// Synchronous fd, not fs.createWriteStream: Playwright's worker process
|
|
32
|
+
// calls process.exit() once its assigned tests finish (it does not wait for
|
|
33
|
+
// the event loop to drain), which cuts off any pending async stream writes
|
|
34
|
+
// before they reach disk. fs.writeSync makes each export() call durable
|
|
35
|
+
// immediately, so span data survives that hard exit — see
|
|
36
|
+
// docs/changes/canary-instrument/proposal.md's ADR reference for why this
|
|
37
|
+
// skill can't assume a graceful shutdown hook will run.
|
|
38
|
+
const outFd = fs.openSync(
|
|
39
|
+
path.join(outDir, `otel-spans.${workerIndex}.jsonl`),
|
|
40
|
+
'a',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Minimal file exporter — one JSON span per line, matches span_reader.mjs. */
|
|
44
|
+
class JsonlFileSpanExporter {
|
|
45
|
+
export(spans, resultCallback) {
|
|
46
|
+
for (const span of spans) {
|
|
47
|
+
const [startSec, startNs] = span.startTime;
|
|
48
|
+
const [durSec, durNs] = span.duration;
|
|
49
|
+
fs.writeSync(outFd, JSON.stringify({
|
|
50
|
+
traceId: span.spanContext().traceId,
|
|
51
|
+
spanId: span.spanContext().spanId,
|
|
52
|
+
// ReadableSpan.parentSpanId was replaced by parentSpanContext in
|
|
53
|
+
// the current @opentelemetry/sdk-trace* line this skill installs
|
|
54
|
+
// (span.parentSpanId is undefined there, so JSON.stringify silently
|
|
55
|
+
// dropped the key entirely) — read the id off parentSpanContext.
|
|
56
|
+
parentSpanId: span.parentSpanContext?.spanId ?? null,
|
|
57
|
+
name: span.name,
|
|
58
|
+
startTime: new Date(startSec * 1000 + startNs / 1e6).toISOString(),
|
|
59
|
+
duration_ms: durSec * 1000 + durNs / 1e6,
|
|
60
|
+
attributes: span.attributes,
|
|
61
|
+
}) + '\n');
|
|
62
|
+
}
|
|
63
|
+
resultCallback({ code: 0 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
shutdown() {
|
|
67
|
+
try {
|
|
68
|
+
fs.closeSync(outFd);
|
|
69
|
+
} catch {
|
|
70
|
+
// already closed (e.g. shutdown() invoked twice) — fine.
|
|
71
|
+
}
|
|
72
|
+
return Promise.resolve();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const spanProcessors = [new SimpleSpanProcessor(new JsonlFileSpanExporter())];
|
|
77
|
+
|
|
78
|
+
if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
79
|
+
spanProcessors.push(
|
|
80
|
+
new SimpleSpanProcessor(
|
|
81
|
+
new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT }),
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sdk = new NodeSDK({
|
|
87
|
+
spanProcessors,
|
|
88
|
+
instrumentations: [
|
|
89
|
+
getNodeAutoInstrumentations({
|
|
90
|
+
'@opentelemetry/instrumentation-fs': { enabled: false },
|
|
91
|
+
}),
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
sdk.start();
|
|
96
|
+
process.on('exit', () => sdk.shutdown());
|
package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// otel_bootstrap/playwright-fixture.ts
|
|
2
|
+
//
|
|
3
|
+
// withTestSpan(base) wraps a Playwright `test` object with an `auto`
|
|
4
|
+
// fixture that opens one root span per test (test.id/test.title/test.file
|
|
5
|
+
// attributes), activates it as the OTel active context so the test's HTTP
|
|
6
|
+
// calls nest as child spans (the whole correlation trick this skill relies
|
|
7
|
+
// on), and closes it in teardown with test.outcome set from
|
|
8
|
+
// testInfo.status. Merge into your own fixtures.ts:
|
|
9
|
+
//
|
|
10
|
+
// import { test as base } from '@playwright/test';
|
|
11
|
+
// import { withTestSpan } from './otel_bootstrap/playwright-fixture';
|
|
12
|
+
// export const test = withTestSpan(base);
|
|
13
|
+
//
|
|
14
|
+
// Root-span-via-fixture (not a custom reporter) is deliberate — reporters
|
|
15
|
+
// run in Playwright's main process and can't establish the OTel active
|
|
16
|
+
// context the HTTP auto-instrumentation needs to nest child spans. See
|
|
17
|
+
// docs/knowledge/decisions/0006-otel-test-side-tracing.md.
|
|
18
|
+
|
|
19
|
+
import type { TestType } from '@playwright/test';
|
|
20
|
+
import { trace, context } from '@opentelemetry/api';
|
|
21
|
+
|
|
22
|
+
const tracer = trace.getTracer('canary-instrument');
|
|
23
|
+
|
|
24
|
+
export function withTestSpan<T extends TestType<any, any>>(base: T): T {
|
|
25
|
+
return base.extend({
|
|
26
|
+
_rootSpan: [
|
|
27
|
+
async ({}, use, testInfo) => {
|
|
28
|
+
const span = tracer.startSpan(testInfo.title, {
|
|
29
|
+
attributes: {
|
|
30
|
+
'test.id': testInfo.titlePath.join(':'),
|
|
31
|
+
'test.title': testInfo.title,
|
|
32
|
+
'test.file': testInfo.file,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
await context.with(trace.setSpan(context.active(), span), async () => {
|
|
36
|
+
await use();
|
|
37
|
+
});
|
|
38
|
+
span.setAttribute('test.outcome', testInfo.status ?? 'unknown');
|
|
39
|
+
span.end();
|
|
40
|
+
},
|
|
41
|
+
{ auto: true },
|
|
42
|
+
] as any,
|
|
43
|
+
}) as T;
|
|
44
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// run_types -- the run.json v1 contract (trace-only), ported from the Python
|
|
2
|
+
// dataclasses one-for-one as the skill moves to JS.
|
|
3
|
+
//
|
|
4
|
+
// Each factory returns a plain object whose keys are declared in exactly the
|
|
5
|
+
// order the Python dataclass declared its fields, so JSON.stringify emits the
|
|
6
|
+
// same key order the old dataclasses.asdict() did. `toDict()` is therefore an
|
|
7
|
+
// identity pass -- the object already IS the on-disk dict shape.
|
|
8
|
+
//
|
|
9
|
+
// No `coverage` key and no `canary_run_id` key exist anywhere in this module:
|
|
10
|
+
// both were cut for v1 (coverage is a separate future skill; canary_run_id has
|
|
11
|
+
// no consumer yet). Additive-only evolution: new optional fields may be
|
|
12
|
+
// appended later; existing fields never change meaning.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{method: string, url: string, route: (string|null),
|
|
16
|
+
* status: (number|null), duration_ms: number, span_id: string,
|
|
17
|
+
* started_at: string}} RequestSpanRow
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{test_id: string, test_title: string, test_file: string,
|
|
22
|
+
* trace_id: string, outcome: string, requests: RequestSpanRow[]}} TestTraceRow
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** One outbound HTTP request span. Field order matches the Python dataclass. */
|
|
26
|
+
export function RequestSpan({
|
|
27
|
+
method,
|
|
28
|
+
url,
|
|
29
|
+
route,
|
|
30
|
+
status,
|
|
31
|
+
duration_ms,
|
|
32
|
+
span_id,
|
|
33
|
+
started_at,
|
|
34
|
+
}) {
|
|
35
|
+
return { method, url, route, status, duration_ms, span_id, started_at };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One test's trace bucket. `test_id` is "__setup__" for orphan (rootless)
|
|
40
|
+
* traffic. `requests` defaults to a fresh empty array (mirrors the Python
|
|
41
|
+
* `field(default_factory=list)`).
|
|
42
|
+
* @param {{test_id: string, test_title: string, test_file: string,
|
|
43
|
+
* trace_id: string, outcome: string, requests?: RequestSpanRow[]}} f
|
|
44
|
+
*/
|
|
45
|
+
export function TestTrace({
|
|
46
|
+
test_id,
|
|
47
|
+
test_title,
|
|
48
|
+
test_file,
|
|
49
|
+
trace_id,
|
|
50
|
+
outcome,
|
|
51
|
+
requests = [],
|
|
52
|
+
}) {
|
|
53
|
+
return { test_id, test_title, test_file, trace_id, outcome, requests };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The trace block: total request spans + per-test buckets.
|
|
58
|
+
* @param {{spans_total: number, by_test?: TestTraceRow[]}} f
|
|
59
|
+
*/
|
|
60
|
+
export function Trace({ spans_total, by_test = [] }) {
|
|
61
|
+
return { spans_total, by_test };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The top-level run.json artifact. */
|
|
65
|
+
export function RunArtifact({
|
|
66
|
+
schema_version,
|
|
67
|
+
suite_type,
|
|
68
|
+
generated_at,
|
|
69
|
+
trace,
|
|
70
|
+
}) {
|
|
71
|
+
return { schema_version, suite_type, generated_at, trace };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Mirror of the Python `RunArtifact.to_dict()` (a plain `asdict()`). Because
|
|
76
|
+
* the factories already build plain nested objects in field order, this is an
|
|
77
|
+
* identity function -- kept for API parity with the Python contract.
|
|
78
|
+
*/
|
|
79
|
+
export function toDict(artifact) {
|
|
80
|
+
return artifact;
|
|
81
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// span_reader -- merge/correlate OTel span JSONL files into a Trace (pure,
|
|
2
|
+
// read-only). Ported from Python span_reader.py, behavior-for-behavior.
|
|
3
|
+
//
|
|
4
|
+
// Reads one or more `otel-spans.<worker>.jsonl` files (one JSON span object
|
|
5
|
+
// per line, written by otel_bootstrap/instrument.mjs), groups spans by
|
|
6
|
+
// `traceId`, resolves each trace's root span (the one carrying a `test.id`
|
|
7
|
+
// attribute -- set by otel_bootstrap/playwright-fixture.ts's root-span
|
|
8
|
+
// fixture), and attaches that trace's HTTP child spans to the resolved test.
|
|
9
|
+
// Traces with no `test.id`-attributed root bucket their HTTP spans under the
|
|
10
|
+
// synthetic test id "__setup__" (traffic outside any test, e.g. global setup).
|
|
11
|
+
//
|
|
12
|
+
// Assumed span envelope: {traceId, spanId, parentSpanId, name, startTime,
|
|
13
|
+
// duration_ms, attributes{}} -- matches exactly what instrument.mjs's
|
|
14
|
+
// JsonlFileSpanExporter writes; no `endTime` key is emitted (duration_ms +
|
|
15
|
+
// startTime cover it). HTTP attributes keyed http.method/http.request.method,
|
|
16
|
+
// http.url, http.route, http.status_code.
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { RequestSpan, TestTrace, Trace } from './run_types.mjs';
|
|
22
|
+
|
|
23
|
+
const SETUP_TEST_ID = '__setup__';
|
|
24
|
+
|
|
25
|
+
// Mirrors Python's Path.glob("otel-spans.*.jsonl"): the `*` matches any run of
|
|
26
|
+
// characters (including none) within a single path segment.
|
|
27
|
+
const SPAN_FILE_RE = /^otel-spans\..*\.jsonl$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read every `otel-spans.*.jsonl` file under `spansDir`, correlate spans to
|
|
31
|
+
* their test roots, and return the Trace. A missing or non-directory path
|
|
32
|
+
* yields an empty trace (never throws) -- the same as the Python version.
|
|
33
|
+
* @param {string} spansDir
|
|
34
|
+
*/
|
|
35
|
+
export function readTraces(spansDir) {
|
|
36
|
+
const byTrace = new Map();
|
|
37
|
+
|
|
38
|
+
if (isDir(spansDir)) {
|
|
39
|
+
const files = fs
|
|
40
|
+
.readdirSync(spansDir)
|
|
41
|
+
.filter((name) => SPAN_FILE_RE.test(name))
|
|
42
|
+
.sort();
|
|
43
|
+
for (const name of files) {
|
|
44
|
+
for (const span of readJsonl(path.join(spansDir, name))) {
|
|
45
|
+
const traceId = span.traceId;
|
|
46
|
+
if (!traceId) continue;
|
|
47
|
+
if (!byTrace.has(traceId)) byTrace.set(traceId, []);
|
|
48
|
+
byTrace.get(traceId).push(span);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const byTest = [];
|
|
54
|
+
const setupRequests = [];
|
|
55
|
+
let spansTotal = 0;
|
|
56
|
+
|
|
57
|
+
for (const [traceId, spans] of byTrace) {
|
|
58
|
+
const root = spans.find(isTestRoot) ?? null;
|
|
59
|
+
const httpSpans = spans.filter((s) => s !== root && isHttpSpan(s));
|
|
60
|
+
const requests = httpSpans.map(toRequestSpan);
|
|
61
|
+
spansTotal += requests.length;
|
|
62
|
+
|
|
63
|
+
if (root === null) {
|
|
64
|
+
setupRequests.push(...requests);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const attrs = root.attributes ?? {};
|
|
69
|
+
byTest.push(
|
|
70
|
+
TestTrace({
|
|
71
|
+
test_id: attrs['test.id'] ?? '',
|
|
72
|
+
test_title: attrs['test.title'] ?? '',
|
|
73
|
+
test_file: attrs['test.file'] ?? '',
|
|
74
|
+
trace_id: traceId,
|
|
75
|
+
outcome: attrs['test.outcome'] ?? '',
|
|
76
|
+
requests,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (setupRequests.length) {
|
|
82
|
+
byTest.push(
|
|
83
|
+
TestTrace({
|
|
84
|
+
test_id: SETUP_TEST_ID,
|
|
85
|
+
test_title: '',
|
|
86
|
+
test_file: '',
|
|
87
|
+
trace_id: '',
|
|
88
|
+
outcome: '',
|
|
89
|
+
requests: setupRequests,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return Trace({ spans_total: spansTotal, by_test: byTest });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isDir(p) {
|
|
98
|
+
try {
|
|
99
|
+
return fs.statSync(p).isDirectory();
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readJsonl(filePath) {
|
|
106
|
+
const spans = [];
|
|
107
|
+
// NDJSON records are \n- or \r\n-delimited. We intentionally do NOT split on
|
|
108
|
+
// the exotic Unicode line separators that Python's str.splitlines() also
|
|
109
|
+
// breaks on (U+2028, U+2029, U+0085, ...): instrument.mjs writes those RAW
|
|
110
|
+
// inside JSON string values, so splitting on them would fragment a valid
|
|
111
|
+
// record and misattribute its request to __setup__.
|
|
112
|
+
for (const raw of fs.readFileSync(filePath, 'utf8').split(/\r\n|\n/)) {
|
|
113
|
+
const line = raw.trim();
|
|
114
|
+
if (!line) continue;
|
|
115
|
+
try {
|
|
116
|
+
spans.push(JSON.parse(line));
|
|
117
|
+
} catch {
|
|
118
|
+
continue; // malformed/torn line (e.g. a crashed worker's last write)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return spans;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isTestRoot(span) {
|
|
125
|
+
return 'test.id' in (span.attributes ?? {});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isHttpSpan(span) {
|
|
129
|
+
const attrs = span.attributes ?? {};
|
|
130
|
+
return 'http.method' in attrs || 'http.request.method' in attrs;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reconstruct the request URL across both OTel HTTP semantic conventions.
|
|
135
|
+
*
|
|
136
|
+
* The old convention put the whole thing in one attribute, `http.url`. The
|
|
137
|
+
* current one (stable since semconv 1.23) splits it: `url.full` on some
|
|
138
|
+
* instrumentations, and otherwise the pieces -- `url.scheme`, `server.address`,
|
|
139
|
+
* `server.port`, `url.path`. `@opentelemetry/auto-instrumentations-node`, which
|
|
140
|
+
* `otel_bootstrap/instrument.mjs` tells consumers to install, emits the SPLIT
|
|
141
|
+
* form and no `http.url` at all.
|
|
142
|
+
*
|
|
143
|
+
* Reading only `http.url` therefore produced `url: ""` on every request while
|
|
144
|
+
* the reader still reported a full span count and exit 0 -- the artifact looked
|
|
145
|
+
* written and was empty of the one field a coverage consumer needs. Port is
|
|
146
|
+
* omitted when it is the scheme default, so the URL matches what a spec or a
|
|
147
|
+
* HAR would say.
|
|
148
|
+
*/
|
|
149
|
+
function requestUrl(attrs) {
|
|
150
|
+
const direct = attrs['url.full'] ?? attrs['http.url'];
|
|
151
|
+
if (direct) return direct;
|
|
152
|
+
|
|
153
|
+
const scheme = attrs['url.scheme'] ?? attrs['http.scheme'];
|
|
154
|
+
const host =
|
|
155
|
+
attrs['server.address'] ?? attrs['net.peer.name'] ?? attrs['http.host'];
|
|
156
|
+
const path = attrs['url.path'] ?? attrs['http.target'] ?? '';
|
|
157
|
+
if (!scheme || !host) return typeof path === 'string' ? path : '';
|
|
158
|
+
|
|
159
|
+
const port = attrs['server.port'] ?? attrs['net.peer.port'];
|
|
160
|
+
const isDefaultPort =
|
|
161
|
+
port == null ||
|
|
162
|
+
(scheme === 'http' && +port === 80) ||
|
|
163
|
+
(scheme === 'https' && +port === 443);
|
|
164
|
+
const authority = isDefaultPort ? host : `${host}:${port}`;
|
|
165
|
+
const query = attrs['url.query'] ? `?${attrs['url.query']}` : '';
|
|
166
|
+
return `${scheme}://${authority}${path}${query}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toRequestSpan(span) {
|
|
170
|
+
const attrs = span.attributes ?? {};
|
|
171
|
+
const method = attrs['http.method'] || attrs['http.request.method'] || '';
|
|
172
|
+
return RequestSpan({
|
|
173
|
+
method,
|
|
174
|
+
url: requestUrl(attrs),
|
|
175
|
+
// `http.route` is a SERVER-side attribute and is never present on the client
|
|
176
|
+
// spans this reader consumes. Kept for producers that do supply it (a future
|
|
177
|
+
// server-side producer under the same v1 contract), but its absence here is
|
|
178
|
+
// normal, not a gap.
|
|
179
|
+
route: attrs['http.route'] ?? null,
|
|
180
|
+
// Renamed in the current convention. Old name kept as a fallback.
|
|
181
|
+
status:
|
|
182
|
+
attrs['http.response.status_code'] ?? attrs['http.status_code'] ?? null,
|
|
183
|
+
duration_ms: span.duration_ms ?? 0,
|
|
184
|
+
span_id: span.spanId ?? '',
|
|
185
|
+
started_at: span.startTime ?? '',
|
|
186
|
+
});
|
|
187
|
+
}
|