From 6e1d66fe8a7ecb86dcebad3de8bc0176d754bfa3 Mon Sep 17 00:00:00 2001 From: Gezziy Date: Tue, 20 Oct 2026 13:12:09 +0100 Subject: [PATCH 1/2] fix(telemetry): validate initialization and warn on uninitialized telemetry access (#768) --- src/index.ts | 3 + src/performance/index.ts | 3 + src/performance/telemetry.test.ts | 155 ++++++++++++++++++++++++++++++ src/performance/telemetry.ts | 111 +++++++++++++++++++-- 4 files changed, 266 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 87f2e6b2..49759df4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1889,6 +1889,9 @@ export { getTelemetryManager, getTelemetryData, isTelemetryInitialized, + validateTelemetryInitialized, + requireTelemetryManager, + requireTelemetryData, shutdownTelemetry, resetTelemetry, traceSpan, diff --git a/src/performance/index.ts b/src/performance/index.ts index f5069842..02b80e01 100644 --- a/src/performance/index.ts +++ b/src/performance/index.ts @@ -4,6 +4,9 @@ export { getTelemetryManager, getTelemetryData, isTelemetryInitialized, + validateTelemetryInitialized, + requireTelemetryManager, + requireTelemetryData, shutdownTelemetry, resetTelemetry, traceSpan, diff --git a/src/performance/telemetry.test.ts b/src/performance/telemetry.test.ts index 4cafddbc..99d13637 100644 --- a/src/performance/telemetry.test.ts +++ b/src/performance/telemetry.test.ts @@ -22,6 +22,9 @@ import { validateTelemetryConfig, resolveOpenTelemetryConfig, DatadogHttpTelemetryExporter, + validateTelemetryInitialized, + requireTelemetryManager, + requireTelemetryData, } from "./telemetry"; import { ok, err, SorokitErrorCode } from "../shared/response"; import type { SorokitResult } from "../shared/response"; @@ -433,4 +436,156 @@ describe("telemetry", () => { expect(isTelemetryInitialized()).toBe(false); }); }); + + describe("initialization validation and uninitialized warning behavior (#768)", () => { + it("emits a console warning when getTelemetryManager() is called before initTelemetry()", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const manager = getTelemetryManager(); + expect(manager).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("getTelemetryManager() was called before initTelemetry()"), + ); + }); + + it("does not spam the console when getTelemetryManager() is called repeatedly (warn-once)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + for (let i = 0; i < 5; i++) { + expect(getTelemetryManager()).toBeNull(); + } + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("emits a warning once when getTelemetryData() is called before initTelemetry()", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(getTelemetryData()).toBeNull(); + expect(getTelemetryData()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("getTelemetryData() was called before initTelemetry()"), + ); + }); + + it("emits a warning once when getTelemetryMetricsSummary() is called before initTelemetry()", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(getTelemetryMetricsSummary()).toEqual({}); + expect(getTelemetryMetricsSummary()).toEqual({}); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("getTelemetryMetricsSummary() was called before initTelemetry()"), + ); + }); + + it("emits a warning once when getTelemetrySpans() is called before initTelemetry()", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(getTelemetrySpans()).toEqual([]); + expect(getTelemetrySpans()).toEqual([]); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("getTelemetrySpans() was called before initTelemetry()"), + ); + }); + + it("does not emit warnings when telemetry has been initialized", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + initTelemetry({ serviceName: "active-service", exporter: "memory" }); + + expect(getTelemetryManager()).not.toBeNull(); + expect(getTelemetryData()?.serviceName).toBe("active-service"); + expect(getTelemetryMetricsSummary()).toBeDefined(); + expect(getTelemetrySpans()).toEqual([]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("resets the warning state when resetTelemetry() is called", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(getTelemetryManager()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + + // Subsequent call does not warn + expect(getTelemetryManager()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + + // Reset telemetry + resetTelemetry(); + + // Should warn again after reset + expect(getTelemetryManager()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it("resets the warning state when shutdownTelemetry() is called", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + initTelemetry({ serviceName: "shutdown-service", exporter: "memory" }); + await shutdownTelemetry(); + + expect(getTelemetryManager()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("resets the warning state when shutdownTelemetry() is called before initialization", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(getTelemetryManager()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + + await shutdownTelemetry(); + + expect(getTelemetryManager()).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it("isTelemetryInitialized() returns false without logging a warning", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(isTelemetryInitialized()).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("validateTelemetryInitialized() returns error when uninitialized and ok when initialized", () => { + const uninit = validateTelemetryInitialized(); + expect(uninit.status).toBe("error"); + if (uninit.status === "error") { + expect(uninit.error.code).toBe(SorokitErrorCode.INVALID_CONFIG); + expect(uninit.error.message).toMatch(/uninitialized/i); + } + + initTelemetry({ serviceName: "val-test", exporter: "memory" }); + const init = validateTelemetryInitialized(); + expect(init.status).toBe("ok"); + }); + + it("requireTelemetryManager() returns error when uninitialized and manager when initialized", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const uninit = requireTelemetryManager(); + expect(uninit.status).toBe("error"); + if (uninit.status === "error") { + expect(uninit.error.code).toBe(SorokitErrorCode.INVALID_CONFIG); + } + expect(warnSpy).toHaveBeenCalledTimes(1); + + initTelemetry({ serviceName: "req-mgr-test", exporter: "memory" }); + const init = requireTelemetryManager(); + expect(init.status).toBe("ok"); + if (init.status === "ok") { + expect(init.data).toBe(getTelemetryManager()); + } + }); + + it("requireTelemetryData() returns error when uninitialized and data when initialized", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const uninit = requireTelemetryData(); + expect(uninit.status).toBe("error"); + if (uninit.status === "error") { + expect(uninit.error.code).toBe(SorokitErrorCode.INVALID_CONFIG); + } + expect(warnSpy).toHaveBeenCalledTimes(1); + + initTelemetry({ serviceName: "req-data-test", exporter: "memory" }); + const init = requireTelemetryData(); + expect(init.status).toBe("ok"); + if (init.status === "ok") { + expect(init.data.serviceName).toBe("req-data-test"); + } + }); + }); }); + diff --git a/src/performance/telemetry.ts b/src/performance/telemetry.ts index 5ae5c033..e491d11e 100644 --- a/src/performance/telemetry.ts +++ b/src/performance/telemetry.ts @@ -275,6 +275,18 @@ export function resolveOpenTelemetryConfig(config: TelemetryConfig): OpenTelemet let activeManager: OpenTelemetryManager | null = null; let activeData: TelemetryData | null = null; +const warnedMethods = new Set(); + +function warnIfUninitialized(methodName: string): void { + if (!activeManager && !warnedMethods.has(methodName)) { + warnedMethods.add(methodName); + console.warn( + `[sorokit:telemetry] ${methodName}() was called before initTelemetry() was invoked. ` + + `Telemetry is uninitialized and operations will silently no-op. ` + + `Call initTelemetry({ serviceName: '...' }) to configure tracing and metrics.`, + ); + } +} /** * Initializes OpenTelemetry-compatible tracing and metrics for the SDK. @@ -315,6 +327,7 @@ export function initTelemetry(config: TelemetryConfig): SorokitResult { + if (!activeManager || !activeManager.isEnabled()) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Telemetry is uninitialized. Call initTelemetry({ serviceName: '...' }) before accessing telemetry.", + ); + } + return ok(undefined); +} + +/** + * Returns the active telemetry manager, or an error `SorokitResult` if uninitialized. + * + * Useful for callers that require strict initialization guarantees without silent no-ops. + */ +export function requireTelemetryManager(): SorokitResult { + if (!activeManager) { + warnIfUninitialized("requireTelemetryManager"); + return err( + SorokitErrorCode.INVALID_CONFIG, + "Telemetry manager is uninitialized. Call initTelemetry({ serviceName: '...' }) before accessing the manager.", + ); + } + return ok(activeManager); +} + +/** + * Returns the active `TelemetryData`, or an error `SorokitResult` if uninitialized. + * + * Useful for callers that require strict initialization guarantees without silent no-ops. + */ +export function requireTelemetryData(): SorokitResult { + if (!activeData) { + warnIfUninitialized("requireTelemetryData"); + return err( + SorokitErrorCode.INVALID_CONFIG, + "Telemetry data is uninitialized. Call initTelemetry({ serviceName: '...' }) before accessing telemetry data.", + ); + } + return ok(activeData); +} + /** * Shuts down the active telemetry manager (flushing/stopping its exporter) * and clears the installed singleton. Safe to call when telemetry was never @@ -348,12 +428,15 @@ export function isTelemetryInitialized(): boolean { */ export async function shutdownTelemetry(): Promise> { if (!activeManager) { + activeData = null; + warnedMethods.clear(); return ok(undefined); } try { await activeManager.shutdown(); activeManager = null; activeData = null; + warnedMethods.clear(); return ok(undefined); } catch (cause) { return err( @@ -371,6 +454,7 @@ export async function shutdownTelemetry(): Promise> { export function resetTelemetry(): void { activeManager = null; activeData = null; + warnedMethods.clear(); } // ─── Tracing Helpers ──────────────────────────────────────────────────────── @@ -460,12 +544,27 @@ export function recordCacheHit( ); } -/** Returns aggregated metric summaries (duration percentiles, counts) from the active manager. */ +/** + * Returns aggregated metric summaries (duration percentiles, counts) from the active manager. + * Emits a console warning if called before `initTelemetry()` (at most once per session). + */ export function getTelemetryMetricsSummary(metricName?: string): Record { - return activeManager ? activeManager.getMetricsSummary(metricName) : {}; + if (!activeManager) { + warnIfUninitialized("getTelemetryMetricsSummary"); + return {}; + } + return activeManager.getMetricsSummary(metricName); } -/** Returns exported spans held by the in-memory exporter, if that's the active exporter. */ +/** + * Returns exported spans held by the in-memory exporter, if that's the active exporter. + * Emits a console warning if called before `initTelemetry()` (at most once per session). + */ export function getTelemetrySpans(): SpanData[] { - return activeManager ? activeManager.getExportedSpans() : []; + if (!activeManager) { + warnIfUninitialized("getTelemetrySpans"); + return []; + } + return activeManager.getExportedSpans(); } + From 2bc2e6bb4cc468f167c11af3debe569b2a2aa702 Mon Sep 17 00:00:00 2001 From: Gezziy Date: Tue, 20 Oct 2026 14:55:49 +0100 Subject: [PATCH 2/2] test(transaction): reset replay protection between tests --- src/tests/integration/transaction-flow.test.ts | 2 ++ src/tests/router-example.test.ts | 2 ++ src/tests/transaction-submit-status.test.ts | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tests/integration/transaction-flow.test.ts b/src/tests/integration/transaction-flow.test.ts index 462a500d..18d04c10 100644 --- a/src/tests/integration/transaction-flow.test.ts +++ b/src/tests/integration/transaction-flow.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createSorokitClient } from "../../client/createSorokitClient"; import { SorokitErrorCode } from "../../shared/response"; +import { clearReplayProtection } from "../../transaction/submitTransaction"; // ─── Hoisted mocks ──────────────────────────────────────────────────────────── @@ -108,6 +109,7 @@ function makeHorizonAccount() { describe("integration: buildPayment → sign → submit", () => { beforeEach(() => { + clearReplayProtection(); mockLoadAccount.mockReset(); mockSubmitTransaction.mockReset(); mockTransactionCall.mockReset(); diff --git a/src/tests/router-example.test.ts b/src/tests/router-example.test.ts index 29bc1980..4a58151a 100644 --- a/src/tests/router-example.test.ts +++ b/src/tests/router-example.test.ts @@ -13,6 +13,7 @@ import { Account, Keypair } from "@stellar/stellar-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SorokitErrorCode, ok, err } from "../shared/response"; import type { SorokitResult } from "../shared/response"; +import { clearReplayProtection } from "../transaction/submitTransaction"; import { WalletType } from "../wallet/types"; import type { SignTransactionInput, WalletAdapter } from "../wallet/types"; import { @@ -118,6 +119,7 @@ function createClient(): RouterSwapClient { beforeEach(() => { vi.clearAllMocks(); + clearReplayProtection(); mockLoadAccount.mockResolvedValue(new Account(source, "1")); mockStrictSendPaths.mockReturnValue(pathsResponse([pathRecord()])); mockStrictReceivePaths.mockReturnValue(pathsResponse([pathRecord()])); diff --git a/src/tests/transaction-submit-status.test.ts b/src/tests/transaction-submit-status.test.ts index bf7d6643..72e4f113 100644 --- a/src/tests/transaction-submit-status.test.ts +++ b/src/tests/transaction-submit-status.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Networks } from "@stellar/stellar-sdk"; import { SorokitErrorCode } from "../shared/response"; -import { submitTransaction } from "../transaction/submitTransaction"; +import { clearReplayProtection, submitTransaction } from "../transaction/submitTransaction"; import { getTransactionStatus } from "../transaction/status"; // --------------------------------------------------------------------------- @@ -90,6 +90,7 @@ function makeHorizonError(status: number, message = "Horizon error") { describe("submitTransaction", () => { beforeEach(() => { vi.clearAllMocks(); + clearReplayProtection(); }); describe("successful submission", () => {