Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1889,6 +1889,9 @@ export {
getTelemetryManager,
getTelemetryData,
isTelemetryInitialized,
validateTelemetryInitialized,
requireTelemetryManager,
requireTelemetryData,
shutdownTelemetry,
resetTelemetry,
traceSpan,
Expand Down
3 changes: 3 additions & 0 deletions src/performance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export {
getTelemetryManager,
getTelemetryData,
isTelemetryInitialized,
validateTelemetryInitialized,
requireTelemetryManager,
requireTelemetryData,
shutdownTelemetry,
resetTelemetry,
traceSpan,
Expand Down
155 changes: 155 additions & 0 deletions src/performance/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
});
});
});

111 changes: 105 additions & 6 deletions src/performance/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,18 @@ export function resolveOpenTelemetryConfig(config: TelemetryConfig): OpenTelemet

let activeManager: OpenTelemetryManager | null = null;
let activeData: TelemetryData | null = null;
const warnedMethods = new Set<string>();

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.
Expand Down Expand Up @@ -315,6 +327,7 @@ export function initTelemetry(config: TelemetryConfig): SorokitResult<TelemetryD

activeManager = manager;
activeData = data;
warnedMethods.clear();

return ok(data);
} catch (cause) {
Expand All @@ -326,13 +339,32 @@ export function initTelemetry(config: TelemetryConfig): SorokitResult<TelemetryD
}
}

/** Returns the currently installed telemetry manager, or `null` if uninitialized. */
/**
* Returns the currently installed telemetry manager, or `null` if uninitialized.
*
* Emits a console warning if called before `initTelemetry()`. To prevent log spam,
* the warning is logged at most once per session until telemetry is initialized or reset.
*/
export function getTelemetryManager(): OpenTelemetryManager | null {
if (!activeManager) {
warnIfUninitialized("getTelemetryManager");
return null;
}
return activeManager;
}

/** Returns the `TelemetryData` from the most recent successful `initTelemetry` call. */
/**
* Returns the `TelemetryData` from the most recent successful `initTelemetry` call,
* or `null` if uninitialized.
*
* Emits a console warning if called before `initTelemetry()`. To prevent log spam,
* the warning is logged at most once per session until telemetry is initialized or reset.
*/
export function getTelemetryData(): TelemetryData | null {
if (!activeData) {
warnIfUninitialized("getTelemetryData");
return null;
}
return activeData;
}

Expand All @@ -341,19 +373,70 @@ export function isTelemetryInitialized(): boolean {
return activeManager !== null && activeManager.isEnabled();
}

/**
* Validates that telemetry has been initialized and is currently active.
*
* Returns an ok `SorokitResult` if active, or an error `SorokitResult` with
* `SorokitErrorCode.INVALID_CONFIG` if uninitialized.
*/
export function validateTelemetryInitialized(): SorokitResult<void> {
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<OpenTelemetryManager> {
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<TelemetryData> {
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
* initialized.
*/
export async function shutdownTelemetry(): Promise<SorokitResult<void>> {
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(
Expand All @@ -371,6 +454,7 @@ export async function shutdownTelemetry(): Promise<SorokitResult<void>> {
export function resetTelemetry(): void {
activeManager = null;
activeData = null;
warnedMethods.clear();
}

// ─── Tracing Helpers ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<string, OtelMetricSummary> {
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();
}

2 changes: 2 additions & 0 deletions src/tests/integration/transaction-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -108,6 +109,7 @@ function makeHorizonAccount() {

describe("integration: buildPayment → sign → submit", () => {
beforeEach(() => {
clearReplayProtection();
mockLoadAccount.mockReset();
mockSubmitTransaction.mockReset();
mockTransactionCall.mockReset();
Expand Down
2 changes: 2 additions & 0 deletions src/tests/router-example.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()]));
Expand Down
Loading
Loading