canary-test-cli 7.1.0 → 8.0.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 +327 -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-batwoman/SKILL.md +119 -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 +187 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -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 +197 -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 +144 -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-screech/SKILL.md +109 -0
- package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
- package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
- package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
- package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
- package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -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 +275 -0
- package/dist/engine/analysis/batwoman/audit.js +39 -0
- package/dist/engine/analysis/batwoman/closure.js +159 -0
- package/dist/engine/analysis/batwoman/gh-history.js +119 -0
- package/dist/engine/analysis/batwoman/probes.js +195 -0
- package/dist/engine/analysis/batwoman/registry.js +142 -0
- package/dist/engine/analysis/batwoman/render.js +194 -0
- package/dist/engine/analysis/batwoman/run-window.js +122 -0
- package/dist/engine/analysis/batwoman/text.js +84 -0
- package/dist/engine/analysis/batwoman/triggers.js +122 -0
- package/dist/engine/analysis/batwoman/verdict.js +64 -0
- package/dist/engine/analysis/cli.js +47 -14
- package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
- package/dist/engine/batwoman-cli.js +119 -0
- package/dist/engine/ci-ready-cli.js +71 -0
- package/dist/engine/cli-commands.js +49 -72
- package/dist/engine/cli.core.js +16 -0
- package/dist/engine/company-knowledge-cli.js +10 -2
- package/dist/engine/core/ci-ready.js +112 -0
- package/dist/engine/core/company-knowledge.js +8 -0
- package/dist/engine/core/migrator.js +147 -20
- package/dist/engine/core/permission-matrix.js +219 -0
- package/dist/engine/core/quality-scorer.js +27 -19
- package/dist/engine/core/scaling-curve.js +143 -0
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +103 -3
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/string-literals.js +3 -1
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/vacuity-scanner.js +330 -15
- package/dist/engine/core/workflow-discovery.js +41 -23
- package/dist/engine/guardian/adjudication-github.js +136 -0
- package/dist/engine/guardian/adjudication.js +119 -340
- package/dist/engine/guardian/analysis-emit.js +7 -2
- package/dist/engine/guardian/cli.js +277 -249
- package/dist/engine/guardian/coverage.js +2 -1
- package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
- package/dist/engine/guardian/diff-coverage/paths.js +5 -9
- package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
- package/dist/engine/guardian/diff-extractor.js +31 -32
- package/dist/engine/guardian/pr-check.js +354 -223
- package/dist/engine/guardian/pr-comment.js +35 -58
- package/dist/engine/guardian/weak-test.js +236 -0
- package/dist/engine/mcp-server.js +67 -4
- package/dist/engine/permission-matrix-cli.js +51 -0
- package/dist/engine/scaling-curve-cli.js +147 -0
- package/dist/engine/skills-cli.js +171 -51
- package/dist/engine/workflow-cli.js +85 -65
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canary-instrument
|
|
3
|
+
description:
|
|
4
|
+
Instrument a Playwright run with OpenTelemetry and emit a run.json artifact
|
|
5
|
+
correlating every test to the outbound HTTP requests it made — "which test
|
|
6
|
+
made which request?" — with zero manual bookkeeping in test code. Trace-only
|
|
7
|
+
v1 contract, additive-safe for future pytest/k6/node producers. Self-contained
|
|
8
|
+
(bundles its own run-type factories and span reader).
|
|
9
|
+
cli: scripts/cli.mjs
|
|
10
|
+
requires: [node>=20]
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Canary Instrument
|
|
14
|
+
|
|
15
|
+
Correlate every outbound HTTP request in a Playwright run to the test that made
|
|
16
|
+
it, using OTel span parent/child relationships. Zero required external
|
|
17
|
+
dependencies to produce output — default file-based span export, no OTel
|
|
18
|
+
collector needed.
|
|
19
|
+
|
|
20
|
+
## Setup (two manual steps, once per suite)
|
|
21
|
+
|
|
22
|
+
This skill ships fixture _files_ you wire into your own suite — it does not
|
|
23
|
+
vendor OTel as a dependency of the `canary` package itself. Install these first:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install --save-dev \
|
|
27
|
+
@opentelemetry/sdk-node @opentelemetry/api \
|
|
28
|
+
@opentelemetry/auto-instrumentations-node \
|
|
29
|
+
@opentelemetry/exporter-trace-otlp-http
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**1. Bootstrap the OTel SDK before Playwright starts** — add `NODE_OPTIONS` to
|
|
33
|
+
your test command (or `playwright.config.ts`'s `webServer`/CI step):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
NODE_OPTIONS="--import ./node_modules/canary/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs" \
|
|
37
|
+
npx playwright test
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Copy `otel_bootstrap/instrument.mjs` into your repo (e.g. `otel/instrument.mjs`)
|
|
41
|
+
if you'd rather not reference the path inside `node_modules`.
|
|
42
|
+
|
|
43
|
+
**2. Merge the root-span fixture into your `fixtures.ts`:**
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { test as base } from '@playwright/test';
|
|
47
|
+
import { withTestSpan } from './otel_bootstrap/playwright-fixture';
|
|
48
|
+
|
|
49
|
+
export const test = withTestSpan(base);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Every test using this `test` export now opens a root span carrying
|
|
53
|
+
`test.id`/`test.title`/`test.file`, and every HTTP call the test makes nests
|
|
54
|
+
under it automatically — no manual span code in individual tests.
|
|
55
|
+
|
|
56
|
+
## Invocation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
canary skills run canary-instrument -- \
|
|
60
|
+
--spans test-results/trace --output test-results \
|
|
61
|
+
[--suite-type e2e_ui]
|
|
62
|
+
|
|
63
|
+
# Usage and options (exits 0; --spans/--output are not required for --help):
|
|
64
|
+
canary skills run canary-instrument -- --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
An unknown flag, a missing required flag, and a value-flag left without its
|
|
68
|
+
value are all usage errors (exit 2). `--help` short-circuits ahead of the
|
|
69
|
+
required-argument check, so it never complains about `--spans`/`--output`.
|
|
70
|
+
|
|
71
|
+
Writes `test-results/run.json`. Creates `--output` if it doesn't exist.
|
|
72
|
+
Missing/empty `--spans` produces `trace: {spans_total: 0, by_test: []}`, not a
|
|
73
|
+
failure. `--suite-type` is a free-form string (no enum) — pass whatever label
|
|
74
|
+
describes your suite.
|
|
75
|
+
|
|
76
|
+
## `run.json` v1 contract (trace-only)
|
|
77
|
+
|
|
78
|
+
```jsonc
|
|
79
|
+
{
|
|
80
|
+
"schema_version": 1,
|
|
81
|
+
"suite_type": "",
|
|
82
|
+
"generated_at": "2026-07-15T18:00:00+00:00",
|
|
83
|
+
"trace": {
|
|
84
|
+
"spans_total": 124,
|
|
85
|
+
"by_test": [
|
|
86
|
+
{
|
|
87
|
+
"test_id": "users-spec:1", // "__setup__" for orphan traffic
|
|
88
|
+
"test_title": "lists users",
|
|
89
|
+
"test_file": "tests/users.spec.ts",
|
|
90
|
+
"trace_id": "abc123...",
|
|
91
|
+
"outcome": "passed",
|
|
92
|
+
"requests": [
|
|
93
|
+
{
|
|
94
|
+
"method": "GET",
|
|
95
|
+
"url": "http://localhost:3000/users/1",
|
|
96
|
+
"route": "/users/:id",
|
|
97
|
+
"status": 200,
|
|
98
|
+
"duration_ms": 12.4,
|
|
99
|
+
"span_id": "def456...",
|
|
100
|
+
"started_at": "2026-07-15T18:00:01+00:00",
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
No `coverage` key, no `canary_run_id` key — cut for v1 (see
|
|
110
|
+
`docs/knowledge/decisions/0006-otel-test-side-tracing.md` and
|
|
111
|
+
`docs/changes/canary-instrument/proposal.md`). Additive-only evolution: new
|
|
112
|
+
optional fields may appear later; existing fields never change meaning.
|
|
113
|
+
|
|
114
|
+
## Sending spans to a collector (optional)
|
|
115
|
+
|
|
116
|
+
Set `OTEL_EXPORTER_OTLP_ENDPOINT` before the test run and spans are
|
|
117
|
+
_additionally_ streamed there — the file exporter still writes
|
|
118
|
+
`test-results/trace/otel-spans.*.jsonl` either way, so `canary-instrument`'s own
|
|
119
|
+
correlation is never dependent on a collector being up. If your org's endpoint
|
|
120
|
+
is recorded in company-knowledge, export it first:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
export OTEL_EXPORTER_OTLP_ENDPOINT="$(canary company-knowledge show --json | jq -r '.otel_exporter_endpoint')"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
See `docs/guides/company-knowledge.md` for the `otel_exporter_endpoint` field.
|
|
127
|
+
|
|
128
|
+
## CI wiring (GitHub Actions)
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
- name: Run Playwright (instrumented)
|
|
132
|
+
env:
|
|
133
|
+
NODE_OPTIONS: '--import ./otel/instrument.mjs'
|
|
134
|
+
run:
|
|
135
|
+
npx playwright test --reporter=json --output-file=test-results/results.json
|
|
136
|
+
|
|
137
|
+
- name: Correlate tests to HTTP spans
|
|
138
|
+
if: always()
|
|
139
|
+
run: |
|
|
140
|
+
canary skills run canary-instrument -- \
|
|
141
|
+
--spans test-results/trace --output test-results
|
|
142
|
+
|
|
143
|
+
- name: Upload run.json
|
|
144
|
+
if: always()
|
|
145
|
+
uses: actions/upload-artifact@v4
|
|
146
|
+
with:
|
|
147
|
+
name: run-trace
|
|
148
|
+
path: test-results/run.json
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Related skills
|
|
152
|
+
|
|
153
|
+
- `canary-test-reporter` — Markdown/JSON test summary; `run.json`'s `by_test[]`
|
|
154
|
+
rows are structurally similar to its `TestResult` shape (title/status as join
|
|
155
|
+
keys) — a future consumer can read both artifacts with one join key.
|
|
156
|
+
- `canary-fail-fast` — aborts a broken run early; use alongside this skill for
|
|
157
|
+
complete CI coverage.
|
|
@@ -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
|
+
}
|