diff --git a/packages/cli/src/ui/commands/perfCommand.test.ts b/packages/cli/src/ui/commands/perfCommand.test.ts new file mode 100644 index 00000000000..f85b3e5e97c --- /dev/null +++ b/packages/cli/src/ui/commands/perfCommand.test.ts @@ -0,0 +1,5 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ diff --git a/packages/cli/src/ui/commands/perfCommand.ts b/packages/cli/src/ui/commands/perfCommand.ts new file mode 100644 index 00000000000..80c19a41a81 --- /dev/null +++ b/packages/cli/src/ui/commands/perfCommand.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MessageType } from '../types.js'; +import type { + HistoryItemPerfLatency, + HistoryItemPerfMemory, + HistoryItemPerfStartup, +} from '../types.js'; +import { type CommandContext } from './types.js'; +import { + getLocalMetricsSnapshot, + localMetricReader, +} from '@google/gemini-cli-core'; + +// Export the bare function, parsing the remaining args to determine the view +export async function handlePerfCommand( + context: CommandContext, + args: string[] = [], +) { + // 1. Force OTel to process the pending queue + await localMetricReader.forceFlush(); + + // 2. Await the Promise to grab the actual data payload + const data = await getLocalMetricsSnapshot(); + + // 3. Figure out which view the user asked for + const targetView = + args.find((arg) => ['latency', 'memory', 'startup'].includes(arg)) || + 'latency'; + + // 4. Dispatch to the correct Ink UI component with explicit type casting + if (targetView === 'memory') { + context.ui.addItem({ + type: MessageType.PERF_MEMORY, + data, + } as HistoryItemPerfMemory); + } else if (targetView === 'startup') { + context.ui.addItem({ + type: MessageType.PERF_STARTUP, + data, + } as HistoryItemPerfStartup); + } else { + context.ui.addItem({ + type: MessageType.PERF_LATENCY, + data, + } as HistoryItemPerfLatency); + } +} diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 1ded0066188..0d4490b0932 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -20,6 +20,7 @@ import { type SlashCommand, CommandKind, } from './types.js'; +import { handlePerfCommand } from './perfCommand.js'; function getUserIdentity(context: CommandContext) { const selectedAuthType = @@ -131,5 +132,47 @@ export const statsCommand: SlashCommand = { } as HistoryItemToolStats); }, }, + { + name: 'perf', + description: 'Show advanced performance metrics and telemetry', + kind: CommandKind.BUILT_IN, + autoExecute: true, + // 1. Change args to be a single string to satisfy the SlashCommand interface + action: async (context: CommandContext, args: string = '') => { + // 2. Safely split the string into an array for your decoupled handler + const argsArray = args ? args.trim().split(/\s+/) : []; + await handlePerfCommand(context, argsArray); + }, + subCommands: [ + { + name: 'latency', + description: 'Show latency P50/P90/P99 percentiles', + kind: CommandKind.BUILT_IN, + autoExecute: true, + // We can just omit 'args' from the parameters here since we hardcode the array + action: async (context: CommandContext) => { + await handlePerfCommand(context, ['latency']); + }, + }, + { + name: 'memory', + description: 'Show V8 heap utilization', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + await handlePerfCommand(context, ['memory']); + }, + }, + { + name: 'startup', + description: 'Show startup phase analysis', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + await handlePerfCommand(context, ['startup']); + }, + }, + ], + }, ], }; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index c9910179a5d..5a835a708f9 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -15,6 +15,7 @@ import { type SkillDefinition, type AgentDefinition, type ApprovalMode, + type PerfSnapshot, CoreToolCallStatus, checkExhaustive, } from '@google/gemini-cli-core'; @@ -221,6 +222,21 @@ export type HistoryItemModel = HistoryItemBase & { model: string; }; +export type HistoryItemPerfLatency = HistoryItemBase & { + type: MessageType.PERF_LATENCY; + data: PerfSnapshot; +}; + +export type HistoryItemPerfMemory = HistoryItemBase & { + type: MessageType.PERF_MEMORY; + data: PerfSnapshot; +}; + +export type HistoryItemPerfStartup = HistoryItemBase & { + type: MessageType.PERF_STARTUP; + data: PerfSnapshot; +}; + export type HistoryItemQuit = HistoryItemBase & { type: 'quit'; duration: string; @@ -368,6 +384,9 @@ export type HistoryItemWithoutId = | HistoryItemStats | HistoryItemModelStats | HistoryItemToolStats + | HistoryItemPerfLatency + | HistoryItemPerfMemory + | HistoryItemPerfStartup | HistoryItemModel | HistoryItemQuit | HistoryItemCompression @@ -393,6 +412,9 @@ export enum MessageType { STATS = 'stats', MODEL_STATS = 'model_stats', TOOL_STATS = 'tool_stats', + PERF_LATENCY = 'perf_latency', + PERF_MEMORY = 'perf_memory', + PERF_STARTUP = 'perf_startup', QUIT = 'quit', GEMINI = 'gemini', COMPRESSION = 'compression', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 258bd78f93a..4fa51b6a884 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1025,10 +1025,8 @@ export class Config implements McpContext { setGeminiMdFilename(params.contextFileName); } - if (this.telemetrySettings.enabled) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - initializeTelemetry(this); - } + // eslint-disable-next-line @typescript-eslint/no-floating-promises + initializeTelemetry(this); const proxy = this.getProxy(); if (proxy) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ce5e77d813..23d2e44a6b5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -193,6 +193,10 @@ export { sessionId, createSessionId } from './utils/session.js'; export * from './utils/compatibility.js'; export * from './utils/browser.js'; export { Storage } from './config/storage.js'; +export { + getLocalMetricsSnapshot, + type PerfSnapshot, +} from './telemetry/localBuffer.js'; // Export hooks system export * from './hooks/index.js'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index adea8939836..61c43c6c997 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -153,3 +153,8 @@ export { export { runInDevTraceSpan, type SpanMetadata } from './trace.js'; export { startupProfiler, StartupProfiler } from './startupProfiler.js'; export * from './constants.js'; +export { + getLocalMetricsSnapshot, + localMetricReader, + type PerfSnapshot, +} from './localBuffer.js'; diff --git a/packages/core/src/telemetry/localBuffer.test.ts b/packages/core/src/telemetry/localBuffer.test.ts new file mode 100644 index 00000000000..f77b5c88ac3 --- /dev/null +++ b/packages/core/src/telemetry/localBuffer.test.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; +import { + setupLocalMetricsReader, + teardownLocalMetrics, + getLocalMetricsSnapshot, +} from './localBuffer.js'; + +describe('Telemetry Local Buffer', () => { + let meterProvider: MeterProvider; + + beforeEach(() => { + // 1. Initialize our fresh local reader using the new factory function + const reader = setupLocalMetricsReader(); + + // 2. Create a fresh OTel engine just for this test, + // passing our custom local bridge directly into the constructor. + meterProvider = new MeterProvider({ + readers: [reader], + }); + }); + + afterEach(async () => { + // Clean up the engine and the singleton after the test finishes + await meterProvider.shutdown(); + teardownLocalMetrics(); + }); + + it('should correctly capture and simplify OTel counters and histograms with attributes', async () => { + const meter = meterProvider.getMeter('test-meter'); + + // 1. Simulate an API Request Counter WITH an attribute + const counter = meter.createCounter('gemini_cli.api.request.count'); + counter.add(10, { model: 'gemini-3.5' }); + counter.add(5, { model: 'gemini-3.5' }); // Total should be 15 for this model + + // 2. Simulate a Tool Execution Latency Histogram WITH an attribute + const histogram = meter.createHistogram('gemini_cli.tool.call.latency'); + histogram.record(100, { function_name: 'search' }); + histogram.record(200, { function_name: 'search' }); + histogram.record(300, { function_name: 'search' }); // Count: 3, Sum: 600 + + // 3. Pull the snapshot through our bridge utility + const snapshot = await getLocalMetricsSnapshot(); + + // 4. Prove the counter was flattened correctly into an array + const requestStats = snapshot.counters['gemini_cli.api.request.count']; + expect(requestStats).toBeDefined(); + expect(requestStats[0].value).toBe(15); + expect(requestStats[0].attributes).toEqual({ model: 'gemini-3.5' }); // Proves we kept granularity! + + // 5. Prove the histogram was flattened correctly into an array + const latencyStats = snapshot.histograms['gemini_cli.tool.call.latency']; + expect(latencyStats).toBeDefined(); + expect(latencyStats[0].count).toBe(3); + expect(latencyStats[0].sum).toBe(600); + expect(latencyStats[0].min).toBe(100); + expect(latencyStats[0].max).toBe(300); + expect(latencyStats[0].attributes).toEqual({ function_name: 'search' }); // Proves we kept granularity! + }); +}); diff --git a/packages/core/src/telemetry/localBuffer.ts b/packages/core/src/telemetry/localBuffer.ts new file mode 100644 index 00000000000..b3b3477e837 --- /dev/null +++ b/packages/core/src/telemetry/localBuffer.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + InMemoryMetricExporter, + PeriodicExportingMetricReader, + DataPointType, + AggregationTemporality, + type ResourceMetrics, +} from '@opentelemetry/sdk-metrics'; + +// Use mutable let variables instead of constants so they can be reset on re-auth +export let localMetricExporter: InMemoryMetricExporter | undefined; +export let localMetricReader: PeriodicExportingMetricReader | undefined; + +/** + * Initializes fresh instances of the local metric reader and exporter. + * Called by the OTel SDK initialization sequence. + */ +export function setupLocalMetricsReader(): PeriodicExportingMetricReader { + localMetricExporter = new InMemoryMetricExporter( + AggregationTemporality.CUMULATIVE, + ); + + localMetricReader = new PeriodicExportingMetricReader({ + exporter: localMetricExporter, + // Fix Issue #4: Set interval to 24 hours (86,400,000 ms). + // We rely entirely on forceFlush() when the /perf command is run. + exportIntervalMillis: 86400000, + }); + + return localMetricReader; +} + +/** + * Clears the local reader references so it can be re-bound during re-auth. + */ +export function teardownLocalMetrics(): void { + localMetricExporter = undefined; + localMetricReader = undefined; +} + +/** + * Simplified representation of current telemetry data, retaining attributes for granularity. + * (Fixes Issue #3) + */ +export interface PerfSnapshot { + counters: Record< + string, + Array<{ value: number; attributes: Record }> + >; + histograms: Record< + string, + Array<{ + count: number; + sum: number; + min?: number; + max?: number; + attributes: Record; + }> + >; +} + +/** + * Forces a flush of the local metric reader and returns a flattened snapshot + * of the current telemetry state. + */ +export const getLocalMetricsSnapshot = async (): Promise => { + // Guard clause in case it hasn't been initialized + if (!localMetricReader || !localMetricExporter) { + return { counters: {}, histograms: {} }; + } + + await localMetricReader.forceFlush(); + // getMetrics() is synchronous, no await needed + const resourceMetricsArray = localMetricExporter.getMetrics(); + localMetricExporter.reset(); + return simplifyMetrics(resourceMetricsArray); +}; + +interface HistogramValue { + count: number; + sum: number; + min?: number; + max?: number; +} + +function isHistogramValue(value: unknown): value is HistogramValue { + if (typeof value !== 'object' || value === null) { + return false; + } + + // Keep this single inline disable to inspect the external object! + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const record = value as Record; + return ( + typeof record['count'] === 'number' && typeof record['sum'] === 'number' + ); +} + +/** + * Flattens OTel ResourceMetrics[] into a localized PerfSnapshot. + */ +function simplifyMetrics( + resourceMetricsArray: ResourceMetrics[] | undefined, +): PerfSnapshot { + const snapshot: PerfSnapshot = { + // Standard objects so the strict linter doesn't throw errors + counters: {}, + histograms: {}, + }; + + if (!resourceMetricsArray || resourceMetricsArray.length === 0) { + return snapshot; + } + + for (const rm of resourceMetricsArray) { + for (const sm of rm.scopeMetrics) { + for (const metric of sm.metrics) { + const name = metric.descriptor.name; + + // Security: Prevent prototype pollution without breaking strict TypeScript typing + if ( + name === '__proto__' || + name === 'constructor' || + name === 'prototype' + ) { + continue; + } + + // 1. Handle regular Counters (SUM) + if (metric.dataPointType === DataPointType.SUM) { + if (!snapshot.counters[name]) { + snapshot.counters[name] = []; + } + + for (const dp of metric.dataPoints) { + const rawValue: unknown = dp.value; + if (typeof rawValue === 'number') { + snapshot.counters[name].push({ + value: rawValue, + attributes: dp.attributes || {}, + }); + } + } + } + + // 2. Handle complex Latency/Memory distributions (HISTOGRAM) + else if (metric.dataPointType === DataPointType.HISTOGRAM) { + if (!snapshot.histograms[name]) { + snapshot.histograms[name] = []; + } + + for (const dp of metric.dataPoints) { + const rawValue: unknown = dp.value; + + if (isHistogramValue(rawValue)) { + snapshot.histograms[name].push({ + count: rawValue.count, + sum: rawValue.sum, + min: rawValue.min !== Infinity ? rawValue.min : undefined, + max: rawValue.max !== -Infinity ? rawValue.max : undefined, + attributes: dp.attributes || {}, + }); + } + } + } + } + } + } + + return snapshot; +} diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index 66b89523db8..344c507aea8 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -20,7 +20,10 @@ import { OTLPLogExporter as OTLPLogExporterHttp } from '@opentelemetry/exporter- import { OTLPMetricExporter as OTLPMetricExporterHttp } from '@opentelemetry/exporter-metrics-otlp-http'; import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base'; import { NodeSDK } from '@opentelemetry/sdk-node'; -import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, +} from '@opentelemetry/semantic-conventions'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { BatchSpanProcessor, @@ -62,6 +65,10 @@ import type { KeychainAvailabilityEvent, TokenStorageInitializationEvent, } from './types.js'; +import { + setupLocalMetricsReader, + teardownLocalMetrics, +} from './localBuffer.js'; // For troubleshooting, set the log level to DiagLogLevel.DEBUG class DiagLoggerAdapter { @@ -160,9 +167,7 @@ export async function initializeTelemetry( config: Config, credentials?: JWTInput, ): Promise { - if (!config.getTelemetryEnabled()) { - return; - } + const isRemoteTelemetryEnabled = config.getTelemetryEnabled(); if (telemetryInitialized) { if ( @@ -176,19 +181,25 @@ export async function initializeTelemetry( return; } - if (config.getTelemetryUseCollector() && config.getTelemetryUseCliAuth()) { + if ( + isRemoteTelemetryEnabled && + config.getTelemetryUseCollector() && + config.getTelemetryUseCliAuth() + ) { debugLogger.error( 'Telemetry configuration error: "useCollector" and "useCliAuth" cannot both be true. ' + 'CLI authentication is only supported with in-process exporters. ' + - 'Disabling telemetry.', + 'Disabling remote telemetry.', ); return; } - // If using CLI auth and no credentials provided, defer initialization - if (config.getTelemetryUseCliAuth() && !credentials) { - // Register a callback to initialize telemetry when the user logs in. - // This is done only once. + // If using CLI auth and no credentials provided, defer ONLY remote initialization + if ( + isRemoteTelemetryEnabled && + config.getTelemetryUseCliAuth() && + !credentials + ) { if (!callbackRegistered) { callbackRegistered = true; authListener = async (newCredentials: JWTInput) => { @@ -200,14 +211,14 @@ export async function initializeTelemetry( authEvents.on('post_auth', authListener); } debugLogger.log( - 'CLI auth is requested but no credentials, deferring telemetry initialization.', + 'CLI auth is requested but no credentials, deferring remote telemetry initialization.', ); return; } const resource = resourceFromAttributes({ - [SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME, - [SemanticResourceAttributes.SERVICE_VERSION]: process.version, + [ATTR_SERVICE_NAME]: SERVICE_NAME, + [ATTR_SERVICE_VERSION]: process.version, 'session.id': config.getSessionId(), }); @@ -231,105 +242,103 @@ export async function initializeTelemetry( ); } - const otlpEndpoint = config.getTelemetryOtlpEndpoint(); - const otlpProtocol = config.getTelemetryOtlpProtocol(); - const telemetryTarget = config.getTelemetryTarget(); - const useCollector = config.getTelemetryUseCollector(); - - const parsedEndpoint = parseOtlpEndpoint(otlpEndpoint, otlpProtocol); - const telemetryOutfile = config.getTelemetryOutfile(); - const useOtlp = !!parsedEndpoint && !telemetryOutfile; - - const gcpProjectId = - process.env['OTLP_GOOGLE_CLOUD_PROJECT'] || - process.env['GOOGLE_CLOUD_PROJECT']; - const useDirectGcpExport = - telemetryTarget === TelemetryTarget.GCP && !useCollector; - - let spanExporter: - | OTLPTraceExporter - | OTLPTraceExporterHttp - | GcpTraceExporter - | FileSpanExporter - | ConsoleSpanExporter; - let logExporter: - | OTLPLogExporter - | OTLPLogExporterHttp - | GcpLogExporter - | FileLogExporter - | ConsoleLogRecordExporter; - let metricReader: PeriodicExportingMetricReader; - - if (useDirectGcpExport) { - debugLogger.log( - 'Creating GCP exporters with projectId:', - gcpProjectId, - 'using', - credentials ? 'provided credentials' : 'ADC', - ); - spanExporter = new GcpTraceExporter(gcpProjectId, credentials); - logExporter = new GcpLogExporter(gcpProjectId, credentials); - metricReader = new PeriodicExportingMetricReader({ - exporter: new GcpMetricExporter(gcpProjectId, credentials), - exportIntervalMillis: 30000, - }); - } else if (useOtlp) { - if (otlpProtocol === 'http') { - spanExporter = new OTLPTraceExporterHttp({ - url: parsedEndpoint, - }); - logExporter = new OTLPLogExporterHttp({ - url: parsedEndpoint, - }); + const activeSpanProcessors: BatchSpanProcessor[] = []; + const activeLogProcessors: BatchLogRecordProcessor[] = []; + // Always push the local reader into the active array for /perf + const activeMetricReaders: PeriodicExportingMetricReader[] = [ + setupLocalMetricsReader(), + ]; + + // Setup cloud/file exporters IF the user consented to telemetry + if (isRemoteTelemetryEnabled) { + const otlpEndpoint = config.getTelemetryOtlpEndpoint(); + const otlpProtocol = config.getTelemetryOtlpProtocol(); + const telemetryTarget = config.getTelemetryTarget(); + const useCollector = config.getTelemetryUseCollector(); + + const parsedEndpoint = parseOtlpEndpoint(otlpEndpoint, otlpProtocol); + const telemetryOutfile = config.getTelemetryOutfile(); + const useOtlp = !!parsedEndpoint && !telemetryOutfile; + + const gcpProjectId = + process.env['OTLP_GOOGLE_CLOUD_PROJECT'] || + process.env['GOOGLE_CLOUD_PROJECT']; + const useDirectGcpExport = + telemetryTarget === TelemetryTarget.GCP && !useCollector; + + let spanExporter; + let logExporter; + let metricReader; + + if (useDirectGcpExport) { + debugLogger.log( + 'Creating GCP exporters with projectId:', + gcpProjectId, + 'using', + credentials ? 'provided credentials' : 'ADC', + ); + spanExporter = new GcpTraceExporter(gcpProjectId, credentials); + logExporter = new GcpLogExporter(gcpProjectId, credentials); metricReader = new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporterHttp({ + exporter: new GcpMetricExporter(gcpProjectId, credentials), + exportIntervalMillis: 30000, + }); + } else if (useOtlp) { + if (otlpProtocol === 'http') { + spanExporter = new OTLPTraceExporterHttp({ url: parsedEndpoint }); + logExporter = new OTLPLogExporterHttp({ url: parsedEndpoint }); + metricReader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporterHttp({ url: parsedEndpoint }), + exportIntervalMillis: 10000, + }); + } else { + spanExporter = new OTLPTraceExporter({ url: parsedEndpoint, - }), + compression: CompressionAlgorithm.GZIP, + }); + logExporter = new OTLPLogExporter({ + url: parsedEndpoint, + compression: CompressionAlgorithm.GZIP, + }); + metricReader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ + url: parsedEndpoint, + compression: CompressionAlgorithm.GZIP, + }), + exportIntervalMillis: 10000, + }); + } + } else if (telemetryOutfile) { + spanExporter = new FileSpanExporter(telemetryOutfile); + logExporter = new FileLogExporter(telemetryOutfile); + metricReader = new PeriodicExportingMetricReader({ + exporter: new FileMetricExporter(telemetryOutfile), exportIntervalMillis: 10000, }); } else { - // grpc - spanExporter = new OTLPTraceExporter({ - url: parsedEndpoint, - compression: CompressionAlgorithm.GZIP, - }); - logExporter = new OTLPLogExporter({ - url: parsedEndpoint, - compression: CompressionAlgorithm.GZIP, - }); + spanExporter = new ConsoleSpanExporter(); + logExporter = new ConsoleLogRecordExporter(); metricReader = new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter({ - url: parsedEndpoint, - compression: CompressionAlgorithm.GZIP, - }), + exporter: new ConsoleMetricExporter(), exportIntervalMillis: 10000, }); } - } else if (telemetryOutfile) { - spanExporter = new FileSpanExporter(telemetryOutfile); - logExporter = new FileLogExporter(telemetryOutfile); - metricReader = new PeriodicExportingMetricReader({ - exporter: new FileMetricExporter(telemetryOutfile), - exportIntervalMillis: 10000, - }); - } else { - spanExporter = new ConsoleSpanExporter(); - logExporter = new ConsoleLogRecordExporter(); - metricReader = new PeriodicExportingMetricReader({ - exporter: new ConsoleMetricExporter(), - exportIntervalMillis: 10000, - }); - } - // Store processor references for manual flushing - spanProcessor = new BatchSpanProcessor(spanExporter); - logRecordProcessor = new BatchLogRecordProcessor(logExporter); + // Store global references so flushTelemetry() still works for remote + spanProcessor = new BatchSpanProcessor(spanExporter); + logRecordProcessor = new BatchLogRecordProcessor(logExporter); + activeSpanProcessors.push(spanProcessor); + activeLogProcessors.push(logRecordProcessor); + activeMetricReaders.push(metricReader); + } + + // Start the SDK with our dynamic pipeline arrays sdk = new NodeSDK({ resource, - spanProcessors: [spanProcessor], - logRecordProcessors: [logRecordProcessor], - metricReader, + spanProcessors: activeSpanProcessors, + logRecordProcessors: activeLogProcessors, + metricReaders: activeMetricReaders, instrumentations: [new HttpInstrumentation()], }); @@ -399,6 +408,10 @@ export async function shutdownTelemetry( } finally { telemetryInitialized = false; sdk = undefined; + + // Clears the local reader singleton to prevent crash on re-auth (Issue #2) + teardownLocalMetrics(); + // Fully reset the global APIs to allow for re-initialization. // This is primarily for testing environments where the SDK is started // and stopped multiple times in the same process.