Files
millionco__react-doctor/packages/react-doctor/tests/with-run-span.test.ts
Ray Arayilakath 738b200e5a feat(cli): send traces, metrics, and the wide event to Axiom (#1609)
* feat(cli): send traces, metrics, and the wide event to Axiom

Moves the CLI's telemetry onto the Axiom transport added in the previous commit.
Sentry keeps crash reporting — its source-map symbolication, issue grouping, and
quotable event id have no Axiom equivalent — so error reports, the prefilled
issue link, and the JSON report's `sentryEventId` are unchanged. Because Effect
exposes a single `Tracer` reference, the two backends are mutually exclusive for
spans, so `tracesSampleRate` is pinned to 0 and the Effect-to-Sentry bridge is
deleted.

Metrics and spans move together because they share one runtime, and that sharing
is load-bearing. `telemetry-runtime.ts` builds the layer once into a long-lived
scope; the run root span, the scan program, and the renderer all draw their
tracer from it. Child spans then nest under the run span natively, with no
`ExternalSpan` stitching. More importantly, Effect tracks delta-temporality state
per metrics exporter instance, so building the layer per program would make every
new exporter re-report each counter in full — silently doubling every metric.
`shutdownTelemetry()` is memoized for the same reason: several exit paths can
fire, and a `--debug` user error flushes before the top-level catch does.

`recordCount` / `recordDistribution` keep their exact signatures, so all ~40 call
sites are untouched; only the body changes to write into Effect's metric
registry. Two constraints differ from Sentry: Effect metric attributes are
string-only, so numbers and booleans are stringified while null and undefined are
dropped rather than coerced to "null"; and histograms need explicit buckets, so
distributions share `METRIC_DISTRIBUTION_BOUNDARIES`. Metrics never touch the
tracer, so they are scrubbed at the emit site instead of by the scrubbing tracer.

`--debug` now prints the Axiom trace id, and crashes carry that id plus `runId`
as Sentry tags — so one pasted id resolves in both backends, replacing the
propagation-context linkage that dies with Sentry tracing.

Verified end to end against a local collector with a real scan: one trace request
and one metrics request, root span with `runInspect` nested under it, the full
wide event, `scan.completed` recorded exactly once, and no home directory or
username on the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): close six review findings in the Axiom cutover

Restore export to a user's own collector. `applyObservability` installed a
backend only when first-party Axiom credentials resolved, so anyone who had
pointed React Doctor at their own OTLP endpoint silently stopped getting spans
once the CLI stopped falling back to `layerUserOtlp` unconditionally. The shared
runtime now builds whenever either first-party credentials or
`REACT_DOCTOR_OTLP_*` are present, and `layerObservability` still prefers the
user's endpoint.

Export metrics for commands that never scan. `version`, `install`, `ci`,
`rules`, and early `inspect` exits record counters like `cli.invoked`, but only
the scan path built the exporter — so `shutdownTelemetry` found no scope and
dropped them. It now ensures the runtime exists before closing it, which is a
no-op when a scan already opened one.

Namespace the environment overrides as `REACT_DOCTOR_AXIOM_*`. The bare
`AXIOM_TOKEN` / `AXIOM_DATASET` names are Axiom's own, and anyone already
running Axiom likely has them exported — reading them would redirect React
Doctor's telemetry into an unrelated account without the user asking.

Carry the Axiom trace id and run id on crash reports as a *context* rather than
tags. Both are unique per run, and tags are the indexed low-cardinality
dimension — AGENTS.md calls this out for `runId` specifically.

Also replaces an inline nanosecond conversion with a named constant, and defers
`LSP_TELEMETRY_EXPORT_INTERVAL_MS` to the commit that actually reads it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cli): embed the Axiom ingest token

Enables first-party telemetry. Until now the constant was empty, so
`resolveAxiomTelemetryOptions` returned null and the CLI recorded into Effect's
registry and dropped everything on exit — the merge blocker called out on this
stack.

The token is `ingest:create` only, scoped to the `react-doctor` (Events) and
`react-doctor-metrics` (OTel Metrics) datasets, with no expiry and no
organization permissions. It ships in the published tarball and is therefore
extractable; that was the deliberate trade (the alternative — proxying ingest
through `www.react.doctor`, which already hosts the score API — remains
available if abuse shows up). Detection is an Axiom monitor on anomalous ingest
volume; rotation is a patch release.

Verified against the live service: `/v1/traces` and `/v1/metrics` both return
200 for protobuf payloads with their respective dataset headers, which also
confirms the protobuf serialization choice end to end rather than just against
a local collector.

Two tests moved with it. They asserted the "no token configured" path, which is
no longer reachable through the environment; they now assert the embedded
defaults resolve, and — the part that actually matters — that Axiom's own
unprefixed `AXIOM_*` variables still cannot redirect the destination.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:14:47 -05:00

87 lines
2.6 KiB
TypeScript

import { afterEach, describe, expect, it } from "vite-plus/test";
import {
recordSentryProjectContext,
resetSentryRunState,
withRunSpan,
} from "../src/cli/utils/with-run-span.js";
import { getActiveRunTrace, setActiveRunTrace } from "../src/cli/utils/active-run-trace.js";
import {
getSentryProjectInfo,
setSentryProjectInfo,
} from "../src/cli/utils/build-sentry-project-context.js";
import type { ProjectInfo } from "@react-doctor/core";
const projectInfo: ProjectInfo = {
rootDirectory: "/workspace/app",
projectName: "my-app",
reactVersion: "19.0.0",
reactMajorVersion: 19,
tailwindVersion: null,
zodVersion: null,
zodMajorVersion: null,
framework: "vite",
hasTypeScript: true,
hasReactCompiler: true,
hasTanStackQuery: false,
tanstackQueryVersion: null,
mobxVersion: null,
styledComponentsVersion: null,
hasSsrDependency: false,
preactVersion: null,
preactMajorVersion: null,
nextjsVersion: null,
nextjsMajorVersion: null,
hasReactNativeWorkspace: false,
expoVersion: null,
shopifyFlashListVersion: null,
shopifyFlashListMajorVersion: null,
hasReanimated: false,
isPreES2023Target: false,
sourceFileCount: 12,
};
describe("recordSentryProjectContext", () => {
afterEach(() => setSentryProjectInfo(null));
it("remembers the project for the lazy error-capture path even without a transaction span", () => {
expect(getSentryProjectInfo()).toBeNull();
recordSentryProjectContext(projectInfo, undefined);
expect(getSentryProjectInfo()).toBe(projectInfo);
});
});
describe("resetSentryRunState", () => {
afterEach(() => {
setSentryProjectInfo(null);
setActiveRunTrace(null);
});
it("clears a prior run's project and trace so they can't leak across inspect() runs", () => {
setSentryProjectInfo(projectInfo);
setActiveRunTrace({ traceId: "trace-1", spanId: "span-1", sampled: true });
resetSentryRunState();
expect(getSentryProjectInfo()).toBeNull();
expect(getActiveRunTrace()).toBeNull();
});
});
describe("withRunSpan", () => {
it("runs the callback with no root span when telemetry is disabled (under tests)", async () => {
let receivedRootSpan: unknown = "unset";
const result = await withRunSpan((rootSpan) => {
receivedRootSpan = rootSpan;
return Promise.resolve("done");
});
expect(result).toBe("done");
expect(receivedRootSpan).toBeUndefined();
});
it("propagates the callback's rejection rather than swallowing it", async () => {
const failure = new Error("scan exploded");
await expect(withRunSpan(() => Promise.reject(failure))).rejects.toBe(failure);
});
});