From e9767c76e6c5f2c9abf30e6428fa24b762b5c4f9 Mon Sep 17 00:00:00 2001 From: afurious Date: Thu, 27 Aug 2026 15:08:58 +0100 Subject: [PATCH] feat(sdk): harden trace IDs, refund grace, preflight, and telemetry spans traceId: extract the crypto.randomUUID-preferring generator into a public generateTraceId(), keeping the Math.random UUID v4 fallback for Node < 19. TraceIdManager and existing callers are unchanged. refundGrace: add an optional graceStartedAt anchor to CanRefundOptions and a new applyPartialRefund() that resets the grace countdown to the current timestamp so full-refund eligibility is re-evaluated from the reset point. Grace period duration is untouched; callers that omit the option keep the deadline-based behaviour. preflight: add runPreflight() performing a HEAD probe against the configured RPC URL with a configurable timeout (default 3s) via the existing withTimeout utility, throwing a new PreflightError with URL and reason on failure. telemetry: add an optional parentSpanId to TelemetryEvent plus an active-span stack (startSpan/endSpan/runInSpan). Events inherit the innermost active span ID, or undefined outside any span. Existing consumers are unaffected. Tests added/extended for all four modules (37 passing). Signed-off-by: afurious --- src/errors.ts | 20 +++++++ src/index.ts | 8 ++- src/preflightChecker.ts | 59 +++++++++++++++++++ src/refundGrace.ts | 59 +++++++++++++++++-- src/telemetry.ts | 61 +++++++++++++++++++- src/traceId.ts | 29 ++++++++-- test/preflightChecker.test.ts | 49 +++++++++++++++- test/refundGrace.test.ts | 104 ++++++++++++++++++++++++++++++++++ test/telemetry.test.ts | 104 ++++++++++++++++++++++++++++++++++ test/traceId.test.ts | 37 +++++++++++- 10 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 test/refundGrace.test.ts create mode 100644 test/telemetry.test.ts diff --git a/src/errors.ts b/src/errors.ts index 02e444b..7a14c08 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -808,6 +808,22 @@ export class RefundGraceError extends StellarSplitError { } } +/** Thrown when a preflight check fails before the SDK attempts contract calls. */ +export class PreflightError extends StellarSplitError { + /** The endpoint URL the failing check targeted. */ + readonly url: string; + /** Human-readable reason the check failed. */ + readonly reason: string; + + constructor(url: string, reason: string) { + super(`Preflight check failed for ${url}: ${reason}`, "PREFLIGHT_ERROR", { url, reason }); + this.name = "PreflightError"; + this.url = url; + this.reason = reason; + Object.setPrototypeOf(this, new.target.prototype); + } +} + /** Thrown when submitting a WaterfallPlan with an unsatisfied tier and `allowPartial` was not set. */ export class WaterfallInsufficientFundsError extends StellarSplitError { readonly invoiceId: string; @@ -1284,6 +1300,10 @@ export function isRefundGraceError(err: unknown): err is RefundGraceError { return err instanceof RefundGraceError; } +export function isPreflightError(err: unknown): err is PreflightError { + return err instanceof PreflightError; +} + export function isChannelReconciliationError(err: unknown): err is ChannelReconciliationError { return err instanceof ChannelReconciliationError; } diff --git a/src/index.ts b/src/index.ts index ef5f0c0..bc9bd11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,7 @@ export { Sep41AdapterError, TrancheProgressError, RefundGraceError, + PreflightError, ChannelReconciliationError, SequenceCacheError, SequenceNumberTooOldError, @@ -156,6 +157,7 @@ export { isSep41AdapterError, isTrancheProgressError, isRefundGraceError, + isPreflightError, isChannelReconciliationError, isSequenceCacheError, isSequenceNumberTooOldError, @@ -474,7 +476,7 @@ export { TimeoutManager, withTimeout, EscalationManager, RequestTimeoutError as export type { TimeoutConfig, EscalationEvent, EscalationCallback } from "./timeout.js"; // Trace IDs (Issue #2) -export { TraceIdManager, globalTraceIdManager } from "./traceId.js"; +export { TraceIdManager, globalTraceIdManager, generateTraceId } from "./traceId.js"; export type { TraceIdGenerator } from "./traceId.js"; // Injectable RpcClient (Issue #3) @@ -484,8 +486,8 @@ export type { RpcClient } from "./rpcClient.js"; export { negotiateVersion, SDK_CONTRACT_VERSION } from "./version.js"; export type { VersionInfo } from "./types.js"; -export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve, checkRecipientFlags } from "./preflightChecker.js"; -export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck, RecipientFlagsCheck } from "./preflightChecker.js"; +export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve, checkRecipientFlags, runPreflight } from "./preflightChecker.js"; +export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck, RecipientFlagsCheck, RunPreflightOptions } from "./preflightChecker.js"; export { inspectFlags, hasAnyRestrictiveFlag } from "./accountFlagsInspector.js"; export type { AccountFlagSet } from "./types.js"; diff --git a/src/preflightChecker.ts b/src/preflightChecker.ts index 331ad07..dd21bb0 100644 --- a/src/preflightChecker.ts +++ b/src/preflightChecker.ts @@ -1,6 +1,8 @@ import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk"; import { inspectFlags } from "./accountFlagsInspector.js"; import type { AccountFlagSet } from "./types.js"; +import { withTimeout, RequestTimeoutError } from "./timeout.js"; +import { PreflightError } from "./errors.js"; export type PayerReadinessReason = | "account_not_found" @@ -330,3 +332,60 @@ export async function checkTrustlineAuthRequirement( const authRequired = (account as unknown as { flags: { auth_required: boolean } }).flags.auth_required === true; return { authRequired }; } + +// --------------------------------------------------------------------------- +// RPC Endpoint Reachability Check +// --------------------------------------------------------------------------- + +/** Default timeout (ms) for the RPC reachability probe. */ +const DEFAULT_PREFLIGHT_TIMEOUT_MS = 3_000; + +/** Options for {@link runPreflight}. */ +export interface RunPreflightOptions { + /** RPC endpoint URL to probe for reachability. */ + rpcUrl: string; + /** Probe timeout in milliseconds. Defaults to 3000. */ + timeoutMs?: number; + /** + * `fetch` implementation to use for the probe. Defaults to the global + * `fetch`. Provided mainly for testing and non-standard runtimes. + */ + fetchImpl?: typeof fetch; +} + +/** + * Verify the configured RPC endpoint is reachable before the SDK attempts + * contract calls, surfacing network problems with a clear error rather than a + * cryptic downstream timeout. + * + * Performs a lightweight HTTP `HEAD` request against `rpcUrl`. Any HTTP + * response (including 4xx / 405) counts as reachable — this checks + * connectivity, not method support. A connection failure, DNS error, or a + * probe that exceeds `timeoutMs` throws a {@link PreflightError} carrying the + * URL and the underlying reason. + * + * @throws {PreflightError} When the endpoint cannot be reached. + */ +export async function runPreflight(options: RunPreflightOptions): Promise { + const { rpcUrl } = options; + const timeoutMs = options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + + if (typeof fetchImpl !== "function") { + throw new PreflightError(rpcUrl, "no fetch implementation available in this runtime"); + } + + try { + await withTimeout( + (signal) => fetchImpl(rpcUrl, { method: "HEAD", signal }), + timeoutMs, + "runPreflight", + ); + } catch (error) { + if (error instanceof RequestTimeoutError) { + throw new PreflightError(rpcUrl, `endpoint did not respond within ${timeoutMs}ms`); + } + const reason = error instanceof Error ? error.message : String(error); + throw new PreflightError(rpcUrl, reason); + } +} diff --git a/src/refundGrace.ts b/src/refundGrace.ts index 205e74d..ef8dfb9 100644 --- a/src/refundGrace.ts +++ b/src/refundGrace.ts @@ -12,26 +12,75 @@ export interface CanRefundOptions { gracePeriodSecs?: number; useOnChainTime?: boolean; server?: SorobanRpc.Server; + /** + * Unix timestamp (seconds) the grace period countdown started from. + * + * When omitted, the countdown is measured from `invoice.deadline`. A partial + * refund resets this to the time it was applied (see {@link applyPartialRefund}), + * so subsequent full-refund eligibility is evaluated from that reset point + * rather than the original deadline. + */ + graceStartedAt?: number; } +/** Result of applying a partial refund: a fresh {@link RefundStatus} plus the reset grace anchor. */ +export interface PartialRefundResult extends RefundStatus { + /** Unix timestamp (seconds) the grace period countdown was reset to. */ + graceStartedAt: number; +} + +// === Time source === + +/** Resolve "now" in seconds, using ledger time when requested and a server is available. */ +async function resolveNow(options: CanRefundOptions): Promise { + return options.useOnChainTime && options.server + ? getLedgerTime(options.server) + : Math.floor(Date.now() / 1000); +} + +/** The point the grace countdown runs from: an explicit reset anchor, else the invoice deadline. */ +function graceAnchor(invoice: Invoice, options: CanRefundOptions): number { + return options.graceStartedAt ?? invoice.deadline; +} + +// === Refund eligibility === + export async function canRefund( invoice: Invoice, options: CanRefundOptions = {}, ): Promise { const gracePeriodSecs = options.gracePeriodSecs ?? 0; - const refundAvailableAt = invoice.deadline + gracePeriodSecs; + const refundAvailableAt = graceAnchor(invoice, options) + gracePeriodSecs; if (invoice.status !== "Pending") { return { canRefund: false, refundAvailableAt, gracePeriodSecs }; } - const now = options.useOnChainTime && options.server - ? await getLedgerTime(options.server) - : Math.floor(Date.now() / 1000); + const now = await resolveNow(options); return { canRefund: now >= refundAvailableAt, refundAvailableAt, gracePeriodSecs }; } +/** + * Apply a partial refund: reset the grace period countdown to the current + * timestamp and re-evaluate full-refund eligibility from that reset point. + * + * The grace period *duration* (`gracePeriodSecs`) is unchanged; only the + * instant it is measured from moves forward to now. Callers should persist the + * returned `graceStartedAt` and pass it back via {@link CanRefundOptions} on + * later {@link canRefund} calls. + */ +export async function applyPartialRefund( + invoice: Invoice, + options: CanRefundOptions = {}, +): Promise { + const gracePeriodSecs = options.gracePeriodSecs ?? 0; + const graceStartedAt = await resolveNow(options); + const status = await canRefund(invoice, { ...options, graceStartedAt }); + + return { ...status, gracePeriodSecs, graceStartedAt }; +} + async function getLedgerTime(server: SorobanRpc.Server): Promise { const ledger = await server.getLatestLedger(); const raw = ledger as { closedAt?: string }; @@ -39,4 +88,4 @@ async function getLedgerTime(server: SorobanRpc.Server): Promise { throw new RefundGraceError("RPC getLatestLedger did not return closedAt; cannot determine ledger time"); } return Math.floor(new Date(raw.closedAt).getTime() / 1000); -} \ No newline at end of file +} diff --git a/src/telemetry.ts b/src/telemetry.ts index 40fadb4..22a1530 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -3,11 +3,17 @@ * No PII (addresses, amounts) is collected. */ -interface TelemetryEvent { +export interface TelemetryEvent { method: string; success: boolean; durationMs: number; timestamp: number; + /** + * ID of the span this event was emitted within, enabling tracing backends to + * reconstruct parent-child relationships. `undefined` when the event was + * emitted outside of any active span. + */ + parentSpanId?: string; } interface TelemetryConfig { @@ -20,6 +26,8 @@ class Telemetry { private events: TelemetryEvent[] = []; private flushInterval: ReturnType | null = null; private readonly FLUSH_INTERVAL_MS = 60000; + /** Stack of active span IDs; the top is the current span for new events. */ + private spanStack: string[] = []; /** * Initialize telemetry with configuration. @@ -34,6 +42,56 @@ class Telemetry { } } + /** + * Mark a span as active. Events recorded until the matching {@link endSpan} + * (or {@link runInSpan} scope exit) inherit `spanId` as their `parentSpanId`. + */ + startSpan(spanId: string): void { + this.spanStack.push(spanId); + } + + /** + * End the most recently started span. When `spanId` is given, entries are + * popped up to and including its first match from the top, tolerating a + * missed `endSpan` call. + */ + endSpan(spanId?: string): void { + if (spanId === undefined) { + this.spanStack.pop(); + return; + } + const idx = this.spanStack.lastIndexOf(spanId); + if (idx !== -1) { + this.spanStack.length = idx; + } + } + + /** + * Run `fn` with `spanId` active, so any telemetry events it records inherit + * that span as their parent. Works for sync and async `fn`; the span is + * always ended, including on throw or rejection. + */ + runInSpan(spanId: string, fn: () => T): T { + this.startSpan(spanId); + let result: T; + try { + result = fn(); + } catch (error) { + this.endSpan(spanId); + throw error; + } + if (result instanceof Promise) { + return result.finally(() => this.endSpan(spanId)) as T; + } + this.endSpan(spanId); + return result; + } + + /** The span ID new events currently inherit, or `undefined` outside any span. */ + private currentSpanId(): string | undefined { + return this.spanStack.length > 0 ? this.spanStack[this.spanStack.length - 1] : undefined; + } + /** * Record a method call. */ @@ -47,6 +105,7 @@ class Telemetry { success, durationMs, timestamp: Date.now(), + parentSpanId: this.currentSpanId(), }); } diff --git a/src/traceId.ts b/src/traceId.ts index b3cbc04..6eb87e7 100644 --- a/src/traceId.ts +++ b/src/traceId.ts @@ -6,11 +6,10 @@ export type TraceIdGenerator = () => string; -function defaultGenerateTraceId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - // Fallback for environments without crypto.randomUUID +// === UUID v4 fallback === + +/** RFC 4122 v4 UUID built from Math.random(); used only when Web Crypto is absent. */ +function uuidV4FromMathRandom(): string { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === "x" ? r : (r & 0x3) | 0x8; @@ -18,6 +17,26 @@ function defaultGenerateTraceId(): string { }); } +// === Trace ID generation === + +/** + * Generate a UUID v4 trace ID. + * + * Prefers `crypto.randomUUID()` from the Web Crypto API, which is + * collision-resistant and suitable for high-throughput usage. Falls back to a + * `Math.random()`-based UUID v4 in runtimes where `crypto.randomUUID` is + * unavailable (e.g. Node.js < 19). The returned value always conforms to the + * UUID v4 format `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`. + */ +export function generateTraceId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return uuidV4FromMathRandom(); +} + +const defaultGenerateTraceId: TraceIdGenerator = generateTraceId; + export class TraceIdManager { private _generator: TraceIdGenerator = defaultGenerateTraceId; diff --git a/test/preflightChecker.test.ts b/test/preflightChecker.test.ts index 2d2d03c..ce20e38 100644 --- a/test/preflightChecker.test.ts +++ b/test/preflightChecker.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { checkPayerReadiness } from "../src/preflightChecker.js"; +import { checkPayerReadiness, runPreflight } from "../src/preflightChecker.js"; +import { PreflightError } from "../src/errors.js"; function makeServer(balances: object[] | null) { return { @@ -75,3 +76,49 @@ describe("checkPayerReadiness", () => { expect(result.reason).toBeUndefined(); }); }); + +describe("runPreflight", () => { + const RPC_URL = "https://rpc.example.org"; + + it("resolves when the endpoint responds to the HEAD probe", async () => { + const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })); + await expect( + runPreflight({ rpcUrl: RPC_URL, fetchImpl: fetchImpl as never }), + ).resolves.toBeUndefined(); + + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe(RPC_URL); + expect((init as RequestInit).method).toBe("HEAD"); + }); + + it("treats any HTTP response (including 4xx) as reachable", async () => { + const fetchImpl = vi.fn(async () => new Response(null, { status: 405 })); + await expect( + runPreflight({ rpcUrl: RPC_URL, fetchImpl: fetchImpl as never }), + ).resolves.toBeUndefined(); + }); + + it("throws PreflightError with the URL and reason when the endpoint is unreachable", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("fetch failed: ECONNREFUSED"); + }); + const err = await runPreflight({ rpcUrl: RPC_URL, fetchImpl: fetchImpl as never }).catch( + (e) => e, + ); + expect(err).toBeInstanceOf(PreflightError); + expect(err.url).toBe(RPC_URL); + expect(err.reason).toContain("ECONNREFUSED"); + }); + + it("throws PreflightError when the probe exceeds the configured timeout", async () => { + // Never settles: the timeout in withTimeout must win the race. + const fetchImpl = vi.fn(() => new Promise(() => {})); + const err = await runPreflight({ + rpcUrl: RPC_URL, + timeoutMs: 20, + fetchImpl: fetchImpl as never, + }).catch((e) => e); + expect(err).toBeInstanceOf(PreflightError); + expect(err.reason).toContain("20ms"); + }); +}); diff --git a/test/refundGrace.test.ts b/test/refundGrace.test.ts new file mode 100644 index 0000000..6fd379c --- /dev/null +++ b/test/refundGrace.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { canRefund, applyPartialRefund } from "../src/refundGrace.js"; +import type { Invoice } from "../src/types.js"; + +const GRACE = 86_400; // 1 day, in seconds + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "1", + creator: "GCREATOR", + recipients: [], + token: "native", + deadline: 1_000_000, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + } as Invoice; +} + +describe("canRefund", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("measures the grace period from invoice.deadline when no reset anchor is given", async () => { + const invoice = makeInvoice({ deadline: 1_000_000 }); + const status = await canRefund(invoice, { gracePeriodSecs: GRACE }); + expect(status.refundAvailableAt).toBe(1_000_000 + GRACE); + }); + + it("measures the grace period from graceStartedAt when provided", async () => { + const invoice = makeInvoice({ deadline: 1_000_000 }); + const status = await canRefund(invoice, { + gracePeriodSecs: GRACE, + graceStartedAt: 2_000_000, + }); + expect(status.refundAvailableAt).toBe(2_000_000 + GRACE); + }); + + it("is not refundable before the grace window elapses", async () => { + vi.useFakeTimers(); + vi.setSystemTime((1_000_000 + 10) * 1000); + const invoice = makeInvoice({ deadline: 1_000_000 }); + const status = await canRefund(invoice, { gracePeriodSecs: GRACE }); + expect(status.canRefund).toBe(false); + }); +}); + +describe("applyPartialRefund", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("resets the grace period timer to the current timestamp", async () => { + vi.useFakeTimers(); + const nowSecs = 5_000_000; + vi.setSystemTime(nowSecs * 1000); + + const invoice = makeInvoice({ deadline: 1_000_000 }); + const result = await applyPartialRefund(invoice, { gracePeriodSecs: GRACE }); + + expect(result.graceStartedAt).toBe(nowSecs); + expect(result.refundAvailableAt).toBe(nowSecs + GRACE); + }); + + it("blocks a full refund requested within the original window after a partial refund", async () => { + vi.useFakeTimers(); + + // Original deadline already passed long ago; without a reset a full refund + // would be available right now. + const invoice = makeInvoice({ deadline: 1_000_000 }); + + const partialAt = 1_000_050; // 50s after the deadline, inside the original grace window + vi.setSystemTime(partialAt * 1000); + const partial = await applyPartialRefund(invoice, { gracePeriodSecs: GRACE }); + expect(partial.canRefund).toBe(false); + + // Still inside the original window, but now also inside the *reset* window. + vi.setSystemTime((partialAt + 100) * 1000); + const afterPartial = await canRefund(invoice, { + gracePeriodSecs: GRACE, + graceStartedAt: partial.graceStartedAt, + }); + expect(afterPartial.canRefund).toBe(false); + expect(afterPartial.refundAvailableAt).toBe(partialAt + GRACE); + + // Once the full reset window elapses, the full refund unlocks. + vi.setSystemTime((partialAt + GRACE + 1) * 1000); + const eligible = await canRefund(invoice, { + gracePeriodSecs: GRACE, + graceStartedAt: partial.graceStartedAt, + }); + expect(eligible.canRefund).toBe(true); + }); + + it("does not change the grace period duration, only its start", async () => { + vi.useFakeTimers(); + vi.setSystemTime(5_000_000 * 1000); + const invoice = makeInvoice({ deadline: 1_000_000 }); + const result = await applyPartialRefund(invoice, { gracePeriodSecs: GRACE }); + expect(result.gracePeriodSecs).toBe(GRACE); + }); +}); diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts new file mode 100644 index 0000000..ca3d18e --- /dev/null +++ b/test/telemetry.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { telemetry } from "../src/telemetry.js"; +import type { TelemetryEvent } from "../src/telemetry.js"; + +// === Test access to the singleton's internal buffer === + +interface TelemetryInternals { + events: TelemetryEvent[]; + spanStack: string[]; +} + +function internals(): TelemetryInternals { + return telemetry as unknown as TelemetryInternals; +} + +function recorded(): TelemetryEvent[] { + return internals().events; +} + +describe("Telemetry parent span context", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 })); + telemetry.init({ endpoint: "https://telemetry.example.org", optOut: false }); + internals().events = []; + internals().spanStack = []; + }); + + afterEach(() => { + telemetry.destroy(); + vi.restoreAllMocks(); + }); + + it("stamps parentSpanId: undefined for events emitted outside any span", () => { + telemetry.recordMethod("getInvoice", true, 12); + expect(recorded()).toHaveLength(1); + expect(recorded()[0].parentSpanId).toBeUndefined(); + }); + + it("inherits the active span ID for events emitted inside runInSpan", () => { + telemetry.runInSpan("span-abc", () => { + telemetry.recordMethod("pay", true, 30); + }); + expect(recorded()[0].parentSpanId).toBe("span-abc"); + }); + + it("inherits the innermost span ID when spans are nested", () => { + telemetry.runInSpan("outer", () => { + telemetry.recordMethod("a", true, 1); + telemetry.runInSpan("inner", () => { + telemetry.recordMethod("b", true, 1); + }); + telemetry.recordMethod("c", true, 1); + }); + const byMethod = Object.fromEntries(recorded().map((e) => [e.method, e.parentSpanId])); + expect(byMethod).toEqual({ a: "outer", b: "inner", c: "outer" }); + }); + + it("restores the previous span even when the callback throws", () => { + expect(() => + telemetry.runInSpan("boom", () => { + throw new Error("kaboom"); + }), + ).toThrow("kaboom"); + telemetry.recordMethod("after", true, 1); + expect(recorded()[0].parentSpanId).toBeUndefined(); + }); + + it("ends an async span only after the promise settles", async () => { + let resolveInner: () => void = () => {}; + const gate = new Promise((r) => { + resolveInner = r; + }); + + const pending = telemetry.runInSpan("async-span", async () => { + await gate; + telemetry.recordMethod("late", true, 5); + }); + + resolveInner(); + await pending; + + expect(recorded()[0].parentSpanId).toBe("async-span"); + expect(internals().spanStack).toHaveLength(0); + }); + + it("startSpan/endSpan explicitly bracket a span", () => { + telemetry.startSpan("manual"); + telemetry.recordMethod("during", true, 2); + telemetry.endSpan("manual"); + telemetry.recordMethod("outside", true, 2); + + expect(recorded()[0].parentSpanId).toBe("manual"); + expect(recorded()[1].parentSpanId).toBeUndefined(); + }); + + it("does not record at all when opted out", () => { + telemetry.destroy(); + telemetry.init({ endpoint: "https://telemetry.example.org", optOut: true }); + telemetry.runInSpan("span-x", () => { + telemetry.recordMethod("pay", true, 30); + }); + expect(recorded()).toHaveLength(0); + }); +}); diff --git a/test/traceId.test.ts b/test/traceId.test.ts index a5e0e1b..f1dceca 100644 --- a/test/traceId.test.ts +++ b/test/traceId.test.ts @@ -1,9 +1,42 @@ -import { describe, it, expect, vi } from "vitest"; -import { TraceIdManager, globalTraceIdManager } from "../src/traceId.js"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { TraceIdManager, globalTraceIdManager, generateTraceId } from "../src/traceId.js"; const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +describe("generateTraceId", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("produces UUID v4 formatted IDs", () => { + expect(generateTraceId()).toMatch(UUID_V4_RE); + }); + + it("produces unique IDs across many calls", () => { + const ids = new Set(Array.from({ length: 1000 }, () => generateTraceId())); + expect(ids.size).toBe(1000); + }); + + it("uses crypto.randomUUID when available", () => { + const randomUUID = vi.fn(() => "11111111-1111-4111-8111-111111111111"); + vi.stubGlobal("crypto", { randomUUID }); + expect(generateTraceId()).toBe("11111111-1111-4111-8111-111111111111"); + expect(randomUUID).toHaveBeenCalledOnce(); + }); + + it("falls back to a Math.random UUID v4 when crypto.randomUUID is absent", () => { + vi.stubGlobal("crypto", {}); + const id = generateTraceId(); + expect(id).toMatch(UUID_V4_RE); + }); + + it("falls back when crypto itself is undefined (Node < 19)", () => { + vi.stubGlobal("crypto", undefined); + expect(generateTraceId()).toMatch(UUID_V4_RE); + }); +}); + describe("TraceIdManager", () => { it("generates UUID v4 by default", () => { const mgr = new TraceIdManager();