Files
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

84 lines
3.0 KiB
TypeScript

import { AXIOM_DEFAULT_DOMAIN } from "@react-doctor/core";
import * as Redacted from "effect/Redacted";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { AXIOM_METRICS_DATASET, AXIOM_TRACES_DATASET } from "../src/cli/utils/constants.js";
import { resolveAxiomTelemetryOptions } from "../src/cli/utils/resolve-axiom-telemetry-options.js";
const TELEMETRY_ENVIRONMENT_VARIABLES = [
"VITEST",
"NODE_ENV",
"REACT_DOCTOR_NO_TELEMETRY",
"REACT_DOCTOR_AXIOM_TOKEN",
"REACT_DOCTOR_AXIOM_DOMAIN",
"REACT_DOCTOR_AXIOM_DATASET",
"REACT_DOCTOR_AXIOM_METRICS_DATASET",
"AXIOM_TOKEN",
"AXIOM_DOMAIN",
"AXIOM_DATASET",
] as const;
let saved: Record<string, string | undefined>;
beforeEach(() => {
saved = {};
for (const name of TELEMETRY_ENVIRONMENT_VARIABLES) {
saved[name] = process.env[name];
delete process.env[name];
}
});
afterEach(() => {
for (const name of TELEMETRY_ENVIRONMENT_VARIABLES) {
const previous = saved[name];
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
}
});
describe("resolveAxiomTelemetryOptions", () => {
it("resolves the embedded first-party defaults when nothing is overridden", () => {
const options = resolveAxiomTelemetryOptions();
expect(options).not.toBeNull();
expect(options?.domain).toBe(AXIOM_DEFAULT_DOMAIN);
expect(options?.tracesDataset).toBe(AXIOM_TRACES_DATASET);
expect(options?.metricsDataset).toBe(AXIOM_METRICS_DATASET);
});
it("ignores Axiom's own unprefixed environment variables", () => {
// A developer who already uses Axiom very likely has these set for their
// own app. Reading them would silently redirect React Doctor's telemetry
// into an unrelated account, or fail against a dataset that isn't shaped
// for it.
process.env.AXIOM_TOKEN = "someone-elses-token";
process.env.AXIOM_DOMAIN = "https://someone-elses-host.invalid";
process.env.AXIOM_DATASET = "someone-elses-dataset";
const options = resolveAxiomTelemetryOptions();
expect(options?.domain).toBe(AXIOM_DEFAULT_DOMAIN);
expect(options?.tracesDataset).toBe(AXIOM_TRACES_DATASET);
expect(Redacted.value(options?.token ?? Redacted.make(""))).not.toBe("someone-elses-token");
});
it("honors the REACT_DOCTOR_-prefixed overrides", () => {
process.env.REACT_DOCTOR_AXIOM_TOKEN = "our-token";
process.env.REACT_DOCTOR_AXIOM_DOMAIN = "https://example.invalid";
process.env.REACT_DOCTOR_AXIOM_DATASET = "our-traces";
process.env.REACT_DOCTOR_AXIOM_METRICS_DATASET = "our-metrics";
const options = resolveAxiomTelemetryOptions();
expect(options?.domain).toBe("https://example.invalid");
expect(options?.tracesDataset).toBe("our-traces");
expect(options?.metricsDataset).toBe("our-metrics");
});
it("stays null when telemetry is opted out even with a token present", () => {
process.env.REACT_DOCTOR_AXIOM_TOKEN = "our-token";
process.env.REACT_DOCTOR_NO_TELEMETRY = "1";
expect(resolveAxiomTelemetryOptions()).toBeNull();
});
});