mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
738b200e5a
* 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>
87 lines
2.6 KiB
TypeScript
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);
|
|
});
|
|
});
|