spinal-obs-node
Version:
WithSpinal cost-aware OpenTelemetry SDK for Node.js
1 lines ⢠83.7 kB
Source Map (JSON)
{"version":3,"sources":["../src/runtime/config.ts","../src/pricing/index.ts","../src/index.ts","../src/public.ts","../src/runtime/tag.ts","../src/runtime/tracer.ts","../node_modules/@opentelemetry/core/src/ExportResult.ts","../src/runtime/exporter.ts","../src/providers/openai.ts","../src/providers/http.ts","../src/analytics/index.ts"],"sourcesContent":["import { diag, DiagLogLevel } from '@opentelemetry/api'\nimport path from 'path'\n\nexport interface Scrubber {\n scrubAttributes(attributes: Record<string, unknown>): Record<string, unknown>\n}\n\nclass DefaultScrubber implements Scrubber {\n private sensitive = [\n /password/i,\n /passwd/i,\n /secret/i,\n /api[._-]?key/i,\n /auth[._-]?token/i,\n /access[._-]?token/i,\n /private[._-]?key/i,\n /encryption[._-]?key/i,\n /bearer/i,\n /credential/i,\n /user[._-]?name/i,\n /first[._-]?name/i,\n /last[._-]?name/i,\n /email/i,\n /email[._-]?address/i,\n /phone[._-]?number/i,\n /ip[._-]?address/i,\n ]\n private protected = [/^attributes$/i, /^spinal\\./i]\n\n scrubAttributes(attributes: Record<string, unknown>): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const [k, v] of Object.entries(attributes ?? {})) {\n if (this.sensitive.some((r) => r.test(k))) {\n out[k] = '[Scrubbed]'\n } else if (Array.isArray(v)) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n out[k] = v.map((x) => (typeof x === 'object' && x !== null ? this.scrubAttributes(x as any) : x))\n } else if (typeof v === 'object' && v !== null) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n out[k] = this.scrubAttributes(v as any)\n } else {\n out[k] = v\n }\n }\n return out\n }\n}\n\nexport interface ConfigureOptions {\n endpoint?: string\n apiKey?: string\n headers?: Record<string, string>\n timeoutMs?: number\n maxQueueSize?: number\n maxExportBatchSize?: number\n scheduleDelayMs?: number\n exportTimeoutMs?: number\n scrubber?: Scrubber\n opentelemetryLogLevel?: DiagLogLevel\n mode?: 'cloud' | 'local'\n localStorePath?: string\n disableLocalMode?: boolean\n}\n\nexport interface SpinalConfig extends Required<Omit<ConfigureOptions, 'scrubber' | 'opentelemetryLogLevel'>> {\n scrubber: Scrubber\n opentelemetryLogLevel: DiagLogLevel\n}\n\nlet globalConfig: SpinalConfig | undefined\n\nexport function configure(opts: ConfigureOptions = {}): SpinalConfig {\n const endpoint = opts.endpoint ?? process.env.SPINAL_TRACING_ENDPOINT ?? 'https://cloud.withspinal.com'\n const apiKey = opts.apiKey ?? process.env.SPINAL_API_KEY ?? ''\n const disableLocalMode = opts.disableLocalMode ?? (process.env.SPINAL_DISABLE_LOCAL_MODE === 'true')\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const inferredMode: 'cloud' | 'local' = (opts.mode ?? (process.env.SPINAL_MODE as any)) || (apiKey ? 'cloud' : 'local')\n const mode = disableLocalMode && !apiKey ? (() => { throw new Error('Cannot disable local mode without providing an API key for cloud mode') })() : inferredMode\n const headers = mode === 'cloud' ? { ...(opts.headers ?? {}), 'X-SPINAL-API-KEY': apiKey } : { ...(opts.headers ?? {}) }\n const timeoutMs = opts.timeoutMs ?? 5_000\n const maxQueueSize = opts.maxQueueSize ?? parseInt(process.env.SPINAL_PROCESS_MAX_QUEUE_SIZE ?? '2048', 10)\n const maxExportBatchSize =\n opts.maxExportBatchSize ?? parseInt(process.env.SPINAL_PROCESS_MAX_EXPORT_BATCH_SIZE ?? '512', 10)\n const scheduleDelayMs = opts.scheduleDelayMs ?? parseInt(process.env.SPINAL_PROCESS_SCHEDULE_DELAY ?? '5000', 10)\n const exportTimeoutMs = opts.exportTimeoutMs ?? parseInt(process.env.SPINAL_PROCESS_EXPORT_TIMEOUT ?? '30000', 10)\n const scrubber = opts.scrubber ?? new DefaultScrubber()\n const opentelemetryLogLevel = opts.opentelemetryLogLevel ?? DiagLogLevel.ERROR\n const localStorePath = opts.localStorePath ?? process.env.SPINAL_LOCAL_STORE_PATH ?? path.join(process.cwd(), '.spinal', 'spans.jsonl')\n\n if (!endpoint) throw new Error('Spinal endpoint must be provided')\n if (mode === 'cloud' && !apiKey) throw new Error('No API key provided. Set SPINAL_API_KEY or pass to configure().')\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n diag.setLogger(console as any, opentelemetryLogLevel)\n\n globalConfig = {\n endpoint,\n apiKey,\n headers,\n timeoutMs,\n maxQueueSize,\n maxExportBatchSize,\n scheduleDelayMs,\n exportTimeoutMs,\n scrubber,\n opentelemetryLogLevel,\n mode,\n localStorePath,\n disableLocalMode,\n }\n return globalConfig\n}\n\nexport function getConfig(): SpinalConfig {\n if (!globalConfig) return configure()\n return globalConfig\n}\n","export type PricingModel = {\n model: string\n inputPer1K: number // USD per 1K input tokens or chars (heuristic)\n outputPer1K: number // USD per 1K output tokens or chars (heuristic)\n}\n\nconst catalog: PricingModel[] = [\n { model: 'openai:gpt-4o-mini', inputPer1K: 0.15, outputPer1K: 0.60 },\n { model: 'openai:gpt-4o', inputPer1K: 2.50, outputPer1K: 10.00 },\n]\n\nexport function estimateCost(params: {\n model?: string\n inputTokens?: number\n outputTokens?: number\n}): number {\n const { model = 'openai:gpt-4o-mini', inputTokens = 0, outputTokens = 0 } = params\n const entry = catalog.find((c) => c.model === model) ?? catalog[0]\n const inputCost = (inputTokens / 1000) * entry.inputPer1K\n const outputCost = (outputTokens / 1000) * entry.outputPer1K\n return roundUSD(inputCost + outputCost)\n}\n\nfunction roundUSD(n: number): number {\n return Math.round(n * 10000) / 10000\n}\n\n\n","export * from './public';\nexport * from './analytics';\n","import { configure as _configure } from './runtime/config';\nimport { tag as _tag } from './runtime/tag';\nimport { instrumentOpenAI as _instrumentOpenAI } from './providers/openai';\nimport { instrumentHTTP as _instrumentHTTP } from './providers/http';\nexport { estimateCost } from './pricing'\n\nexport const configure = _configure;\nexport const tag = _tag;\nexport const instrumentOpenAI = _instrumentOpenAI;\nexport const instrumentHTTP = _instrumentHTTP;\n\nexport { shutdown, forceFlush } from './runtime/tracer';\nexport type { ConfigureOptions, Scrubber } from './runtime/config';\n\n// Local data display function\nexport async function displayLocalData(options: {\n limit?: number;\n format?: 'table' | 'json' | 'summary';\n} = {}) {\n const { getConfig } = await import('./runtime/config');\n const { estimateCost } = await import('./pricing');\n const fs = await import('fs');\n \n const cfg = getConfig();\n const file = cfg.localStorePath;\n \n if (!fs.existsSync(file)) {\n console.log('No local data found. Start your application with Spinal configured to collect data.');\n return;\n }\n\n const raw = await fs.promises.readFile(file, 'utf8');\n const lines = raw.trim().length ? raw.trim().split('\\n') : [];\n \n if (lines.length === 0) {\n console.log('No spans collected yet. Start your application with Spinal configured to collect data.');\n return;\n }\n\n const spans = [];\n for (const line of lines) {\n try {\n const span = JSON.parse(line);\n spans.push(span);\n } catch {\n // Ignore malformed JSON lines\n }\n }\n\n const limit = options.limit || 10;\n const format = options.format || 'table';\n const displaySpans = spans.slice(-limit); // Show most recent spans\n\n if (format === 'json') {\n console.log(JSON.stringify(displaySpans, null, 2));\n } else if (format === 'summary') {\n const summary = {\n totalSpans: spans.length,\n uniqueTraces: new Set(spans.map(s => s.trace_id)).size,\n spanTypes: spans.reduce((acc, span) => {\n const type = span.name || 'unknown';\n acc[type] = (acc[type] || 0) + 1;\n return acc;\n }, {} as Record<string, number>),\n estimatedCost: spans.reduce((total, span) => {\n const attrs = span.attributes || {};\n const inputTokens = Number(attrs['spinal.input_tokens'] || 0);\n const outputTokens = Number(attrs['spinal.output_tokens'] || 0);\n const model = String(attrs['spinal.model'] || 'openai:gpt-4o-mini');\n return total + estimateCost({ model, inputTokens, outputTokens });\n }, 0)\n };\n console.log(JSON.stringify(summary, null, 2));\n } else {\n // Default table format\n console.log(`\\nš Spinal Local Data (showing last ${displaySpans.length} of ${spans.length} spans)\\n`);\n console.log('ā'.repeat(120));\n console.log(`${'Name'.padEnd(30)} ${'Trace ID'.padEnd(32)} ${'Duration (ms)'.padEnd(12)} ${'Status'.padEnd(8)} ${'Model'.padEnd(15)} ${'Cost ($)'.padEnd(8)}`);\n console.log('ā'.repeat(120));\n \n for (const span of displaySpans) {\n const name = (span.name || 'unknown').substring(0, 29).padEnd(30);\n const traceId = span.trace_id.substring(0, 31).padEnd(32);\n const duration = span.end_time && span.start_time \n ? ((span.end_time - span.start_time) / 1000000).toFixed(1).padEnd(12)\n : 'N/A'.padEnd(12);\n const status = String(span.status?.code || 'UNSET').padEnd(8);\n \n const attrs = span.attributes || {};\n const model = (attrs['spinal.model'] || 'N/A').toString().substring(0, 14).padEnd(15);\n const inputTokens = Number(attrs['spinal.input_tokens'] || 0);\n const outputTokens = Number(attrs['spinal.output_tokens'] || 0);\n const cost = inputTokens > 0 || outputTokens > 0 \n ? estimateCost({ \n model: String(attrs['spinal.model'] || 'openai:gpt-4o-mini'), \n inputTokens, \n outputTokens \n }).toFixed(4).padEnd(8)\n : 'N/A'.padEnd(8);\n \n console.log(`${name} ${traceId} ${duration} ${status} ${model} ${cost}`);\n }\n console.log('ā'.repeat(120));\n \n // Show summary at bottom\n const totalCost = spans.reduce((total, span) => {\n const attrs = span.attributes || {};\n const inputTokens = Number(attrs['spinal.input_tokens'] || 0);\n const outputTokens = Number(attrs['spinal.output_tokens'] || 0);\n const model = String(attrs['spinal.model'] || 'openai:gpt-4o-mini');\n return total + estimateCost({ model, inputTokens, outputTokens });\n }, 0);\n \n console.log(`\\nš° Total estimated cost: $${totalCost.toFixed(4)}`);\n console.log(`š Total spans collected: ${spans.length}`);\n console.log(`š Unique traces: ${new Set(spans.map(s => s.trace_id)).size}`);\n }\n}\n","import { context, propagation } from '@opentelemetry/api'\nimport { getIsolatedProvider } from './tracer'\n\nconst SPINAL_NAMESPACE = 'spinal'\n\nexport function tag(tags: Record<string, string | number | undefined> & { aggregationId?: string | number } = {}) {\n let ctx = context.active()\n const entries: [string, string][] = []\n\n if (tags.aggregationId) {\n entries.push([`${SPINAL_NAMESPACE}.aggregation_id`, String(tags.aggregationId)])\n }\n\n for (const [k, v] of Object.entries(tags)) {\n if (k === 'aggregationId') continue\n if (v === undefined) continue\n entries.push([`${SPINAL_NAMESPACE}.${k}`, String(v)])\n }\n\n const currentBaggage = propagation.getBaggage(ctx) ?? propagation.createBaggage()\n const updated = entries.reduce((bag, [k, v]) => bag.setEntry(k, { value: v }), currentBaggage)\n ctx = propagation.setBaggage(ctx, updated)\n \n // Create a span to ensure the tags get exported\n const provider = getIsolatedProvider()\n const tracer = provider.getTracer('spinal-tag')\n \n const span = tracer.startSpan('spinal.tag', undefined, ctx)\n \n // Set the tags as span attributes\n entries.forEach(([key, value]) => {\n span.setAttribute(key, value)\n })\n \n // End the span immediately to ensure it gets exported\n span.end()\n \n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const token = (context as any).attach?.(ctx) ?? undefined\n return {\n dispose() {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n if (token && (context as any).detach) (context as any).detach(token)\n },\n }\n}\n","import { BatchSpanProcessor, AlwaysOnSampler } from '@opentelemetry/sdk-trace-base'\nimport { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'\nimport type { ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'\nimport { context as otContext, propagation } from '@opentelemetry/api'\nimport type { Baggage, BaggageEntry } from '@opentelemetry/api'\nimport { getConfig } from './config'\nimport { SpinalExporter } from './exporter'\n\nconst SPINAL_NAMESPACE = 'spinal'\n\nexport class SpinalSpanProcessor extends BatchSpanProcessor {\n private exporter: SpinalExporter\n constructor() {\n const cfg = getConfig()\n const exporter = new SpinalExporter()\n super(exporter, {\n maxQueueSize: cfg.maxQueueSize,\n scheduledDelayMillis: cfg.scheduleDelayMs,\n maxExportBatchSize: cfg.maxExportBatchSize,\n exportTimeoutMillis: cfg.exportTimeoutMs,\n })\n this.exporter = exporter\n }\n\n private excludedHosts = (() => {\n const defaultHosts = ['api.anthropic.com', 'api.azure.com'] // Removed api.openai.com from default exclusions\n const override = process.env.SPINAL_EXCLUDED_HOSTS?.trim()\n const list = override && override.length > 0 ? override.split(',').map((s) => s.trim()).filter(Boolean) : defaultHosts\n const set = new Set(list)\n // Only exclude OpenAI if explicitly configured to do so\n if (process.env.SPINAL_EXCLUDE_OPENAI === 'true') {\n set.add('api.openai.com')\n }\n return set\n })()\n\n private shouldProcess(span: ReadableSpan | Span): boolean {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const scopeName = (span as any).instrumentationLibrary?.name || (span as any).instrumentationScope?.name || ''\n if (!scopeName) return false\n \n // Always process Spinal spans\n if (scopeName.includes('spinal-')) return true\n \n if (scopeName.includes('http')) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const url = (span as any).attributes?.['http.url'] as string | undefined\n try {\n if (url) {\n const host = new URL(url).host\n if (this.excludedHosts.has(host)) return false\n }\n } catch {\n // Ignore invalid URLs\n }\n return true\n }\n if (scopeName.includes('openai') || scopeName.includes('anthropic') || scopeName.includes('openai_agents')) return true\n return false\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n onStart(span: Span, parentContext: any): void {\n if (!this.shouldProcess(span)) return\n const bag: Baggage | undefined = propagation.getBaggage(parentContext ?? otContext.active())\n if (!bag) return\n const entries = bag.getAllEntries() as [string, BaggageEntry][]\n entries.forEach(([key, entry]) => {\n if (key.startsWith(`${SPINAL_NAMESPACE}.`)) {\n span.setAttribute(key, String(entry.value))\n }\n })\n }\n}\n\nlet providerSingleton: NodeTracerProvider | undefined\n\nexport function getIsolatedProvider(): NodeTracerProvider {\n if (providerSingleton) return providerSingleton\n const provider = new NodeTracerProvider({\n sampler: new AlwaysOnSampler(),\n spanProcessors: [new SpinalSpanProcessor()],\n })\n provider.register()\n providerSingleton = provider\n return provider\n}\n\nexport async function shutdown(): Promise<void> {\n if (!providerSingleton) return\n await providerSingleton.shutdown()\n}\n\nexport async function forceFlush(): Promise<void> {\n if (!providerSingleton) return\n await providerSingleton.forceFlush()\n}\n","/*\n * Copyright The OpenTelemetry Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport interface ExportResult {\n code: ExportResultCode;\n error?: Error;\n}\n\nexport enum ExportResultCode {\n SUCCESS,\n FAILED,\n}\n","import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'\nimport { ExportResultCode, type ExportResult } from '@opentelemetry/core'\nimport { getConfig } from './config'\nimport fs from 'fs'\nimport path from 'path'\nimport { request } from 'undici'\n\nexport class SpinalExporter implements SpanExporter {\n private shutdownFlag = false\n\n async export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): Promise<void> {\n if (this.shutdownFlag) return resultCallback({ code: ExportResultCode.FAILED })\n try {\n const cfg = getConfig()\n const payload = spans.map((s) => this.toJSON(s))\n\n if (cfg.mode === 'local') {\n await this.writeLocal(cfg.localStorePath, payload)\n resultCallback({ code: ExportResultCode.SUCCESS })\n return\n }\n\n const body = { spans: payload }\n const res = await request(cfg.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...cfg.headers },\n body: JSON.stringify(body),\n bodyTimeout: cfg.timeoutMs,\n headersTimeout: cfg.timeoutMs,\n })\n if (res.statusCode >= 200 && res.statusCode < 300) {\n resultCallback({ code: ExportResultCode.SUCCESS })\n } else {\n resultCallback({ code: ExportResultCode.FAILED })\n }\n } catch (err) {\n resultCallback({ code: ExportResultCode.FAILED, error: err as Error })\n }\n }\n\n shutdown(): Promise<void> {\n this.shutdownFlag = true\n return Promise.resolve()\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private toJSON(span: ReadableSpan): any {\n const cfg = getConfig()\n const attributes = { ...(span.attributes ?? {}) }\n const scrubbed = cfg.scrubber.scrubAttributes(attributes)\n\n return {\n name: span.name,\n trace_id: span.spanContext().traceId,\n span_id: span.spanContext().spanId,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n parent_span_id: (span as any).parentSpanId ?? null,\n start_time: span.startTime,\n end_time: span.endTime,\n status: span.status ?? null,\n attributes: scrubbed,\n events: (span.events ?? []).map((e) => ({ name: e.name, timestamp: e.time, attributes: e.attributes ?? {} })),\n links: (span.links ?? []).map((l) => ({\n context: { trace_id: l.context.traceId, span_id: l.context.spanId },\n attributes: l.attributes ?? {},\n })),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n instrumentation_info: ((span as any).instrumentationLibrary || (span as any).instrumentationScope\n ? {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n name: (span as any).instrumentationLibrary?.name || (span as any).instrumentationScope?.name,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n version: (span as any).instrumentationLibrary?.version || (span as any).instrumentationScope?.version,\n }\n : null),\n }\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private async writeLocal(filePath: string, payload: any[]): Promise<void> {\n if (payload.length === 0) {\n return // Don't create file if there's no data to write\n }\n \n try {\n await fs.promises.mkdir(path.dirname(filePath), { recursive: true })\n const lines = payload.map((p) => JSON.stringify(p)).join('\\n') + '\\n'\n \n // Use atomic append operation - creates file if missing and opens with O_APPEND\n await fs.promises.appendFile(filePath, lines, 'utf8')\n } catch (error) {\n // In test environments or when directory creation fails, just log and continue\n // This prevents tests from failing due to file system issues\n console.warn(`Failed to write local spans to ${filePath}:`, error)\n }\n }\n}\n","import { getIsolatedProvider } from '../runtime/tracer'\nimport { trace, SpanStatusCode } from '@opentelemetry/api'\n\n// OpenAI API response structure\ninterface OpenAIUsage {\n prompt_tokens: number\n completion_tokens: number\n total_tokens: number\n}\n\ninterface OpenAIResponse {\n usage?: OpenAIUsage\n model?: string\n}\n\n// Store response data for processing\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst responseDataMap = new WeakMap<object, { body: string; span: any }>()\n\nexport async function instrumentOpenAI() {\n getIsolatedProvider() // ensure provider exists\n \n // Intercept fetch calls to OpenAI API\n const originalFetch = global.fetch\n global.fetch = async function(input: string | URL | Request, init?: RequestInit) {\n const url = typeof input === 'string' ? input : input.toString()\n \n if (url.includes('api.openai.com')) {\n const tracer = trace.getTracer('spinal-openai')\n const span = tracer.startSpan('openai-api-call')\n \n try {\n // Extract model from request body if available\n if (init?.body) {\n try {\n const bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body)\n const parsed = JSON.parse(bodyStr)\n if (parsed.model) {\n span.setAttribute('spinal.model', `openai:${parsed.model}`)\n }\n } catch {\n // Ignore parsing errors\n }\n }\n \n // Make the actual request\n const response = await originalFetch(input, init)\n \n // Clone the response to read its body\n const clonedResponse = response.clone()\n const responseText = await clonedResponse.text()\n \n // Store response data for processing\n responseDataMap.set(response, { body: responseText, span })\n \n // Parse response for token usage\n try {\n const parsed: OpenAIResponse = JSON.parse(responseText)\n \n if (parsed.usage) {\n span.setAttribute('spinal.input_tokens', parsed.usage.prompt_tokens)\n span.setAttribute('spinal.output_tokens', parsed.usage.completion_tokens)\n span.setAttribute('spinal.total_tokens', parsed.usage.total_tokens)\n }\n \n if (parsed.model) {\n span.setAttribute('spinal.model', `openai:${parsed.model}`)\n }\n \n // Store full response data as span attribute (similar to Python SDK)\n span.setAttribute('spinal.response.binary_data', responseText)\n span.setAttribute('spinal.response.size', responseText.length)\n span.setAttribute('spinal.response.capture_method', 'fetch_clone')\n } catch {\n // Ignore parsing errors\n }\n \n span.setStatus({ code: SpanStatusCode.OK })\n span.end()\n \n return response\n } catch (error) {\n span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message })\n span.end()\n throw error\n }\n }\n \n // For non-OpenAI requests, use original fetch\n return originalFetch(input, init)\n }\n \n // Also intercept Node.js http/https requests if available\n try {\n const http = await import('http')\n const https = await import('https')\n \n // Intercept http requests\n const originalHttpRequest = http.request\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n http.request = function(options: any, callback?: any) {\n const url = options.hostname || options.host || ''\n if (url.includes('api.openai.com')) {\n const tracer = trace.getTracer('spinal-openai')\n const span = tracer.startSpan('openai-api-call')\n \n // Store span for later processing\n const originalCallback = callback\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n callback = function(res: any) {\n // Intercept response to capture token usage\n const chunks: Buffer[] = []\n res.on('data', (chunk: Buffer) => chunks.push(chunk))\n res.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString()\n const parsed: OpenAIResponse = JSON.parse(body)\n \n if (parsed.usage) {\n span.setAttribute('spinal.input_tokens', parsed.usage.prompt_tokens)\n span.setAttribute('spinal.output_tokens', parsed.usage.completion_tokens)\n span.setAttribute('spinal.total_tokens', parsed.usage.total_tokens)\n }\n \n if (parsed.model) {\n span.setAttribute('spinal.model', `openai:${parsed.model}`)\n }\n \n // Store full response data as span attribute (similar to Python SDK)\n span.setAttribute('spinal.response.binary_data', body)\n span.setAttribute('spinal.response.size', body.length)\n span.setAttribute('spinal.response.capture_method', 'http_stream')\n \n span.setStatus({ code: SpanStatusCode.OK })\n span.end()\n } catch {\n span.setStatus({ code: SpanStatusCode.OK })\n span.end()\n }\n })\n \n if (originalCallback) originalCallback(res)\n }\n }\n \n return originalHttpRequest.call(this, options, callback)\n }\n \n // Intercept https requests\n const originalHttpsRequest = https.request\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n https.request = function(options: any, callback?: any) {\n const url = options.hostname || options.host || ''\n if (url.includes('api.openai.com')) {\n const tracer = trace.getTracer('spinal-openai')\n const span = tracer.startSpan('openai-api-call')\n \n // Store span for later processing\n const originalCallback = callback\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n callback = function(res: any) {\n // Intercept response to capture token usage\n const chunks: Buffer[] = []\n res.on('data', (chunk: Buffer) => chunks.push(chunk))\n res.on('end', () => {\n try {\n const body = Buffer.concat(chunks).toString()\n const parsed: OpenAIResponse = JSON.parse(body)\n \n if (parsed.usage) {\n span.setAttribute('spinal.input_tokens', parsed.usage.prompt_tokens)\n span.setAttribute('spinal.output_tokens', parsed.usage.completion_tokens)\n span.setAttribute('spinal.total_tokens', parsed.usage.total_tokens)\n }\n \n if (parsed.model) {\n span.setAttribute('spinal.model', `openai:${parsed.model}`)\n }\n \n // Store full response data as span attribute (similar to Python SDK)\n span.setAttribute('spinal.response.binary_data', body)\n span.setAttribute('spinal.response.size', body.length)\n span.setAttribute('spinal.response.capture_method', 'https_stream')\n \n span.setStatus({ code: SpanStatusCode.OK })\n span.end()\n } catch {\n span.setStatus({ code: SpanStatusCode.OK })\n span.end()\n }\n })\n \n if (originalCallback) originalCallback(res)\n }\n }\n \n return originalHttpsRequest.call(this, options, callback)\n }\n } catch {\n // Node.js http/https modules not available (browser environment)\n }\n}\n","import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'\nimport { getIsolatedProvider } from '../runtime/tracer'\n\nexport function instrumentHTTP() {\n getIsolatedProvider() // ensure provider exists\n \n const httpInstr = new HttpInstrumentation({\n // Intercept request to capture OpenAI-specific data\n requestHook: (span, request) => {\n // Extract URL from various possible sources\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const url = (request as any).url || (request as any).path || (request as any).href || ''\n \n if (typeof url === 'string' && url.includes('api.openai.com')) {\n // Mark this as an OpenAI request\n span.setAttribute('spinal.provider', 'openai')\n \n // Try to extract model from request body if available\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const body = (request as any).body\n if (body) {\n try {\n const bodyStr = typeof body === 'string' ? body : JSON.stringify(body)\n const parsed = JSON.parse(bodyStr)\n if (parsed.model) {\n span.setAttribute('spinal.model', `openai:${parsed.model}`)\n }\n } catch {\n // Ignore parsing errors\n }\n }\n }\n }\n })\n \n httpInstr.enable()\n}\n","import { readFileSync } from 'fs'\nimport { join } from 'path'\nimport { estimateCost } from '../pricing'\n\nexport interface Span {\n name: string\n trace_id: string\n span_id: string\n parent_span_id: string | null\n start_time: [number, number]\n end_time: [number, number]\n status: { code: number }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attributes: Record<string, any>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n events: any[]\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n links: any[]\n instrumentation_info: { name: string; version: string }\n}\n\nexport interface AnalyticsOptions {\n since?: string // e.g., \"7d\", \"24h\", \"1h\"\n format?: 'table' | 'json' | 'csv' | 'summary'\n byModel?: boolean\n byAggregation?: boolean\n trends?: boolean\n}\n\nexport interface CostAnalysis {\n totalCost: number\n totalCalls: number\n averageCostPerCall: number\n costByModel: Record<string, { cost: number; calls: number; percentage: number }>\n costByAggregation: Record<string, { cost: number; calls: number; percentage: number }>\n costTrends: Array<{ date: string; cost: number; calls: number }>\n}\n\nexport interface UsageAnalysis {\n totalCalls: number\n totalTokens: number\n inputTokens: number\n outputTokens: number\n usageByModel: Record<string, { calls: number; tokens: number; percentage: number }>\n usageByAggregation: Record<string, { calls: number; tokens: number; percentage: number }>\n tokenEfficiency: {\n averageInputTokensPerCall: number\n averageOutputTokensPerCall: number\n tokenRatio: number\n }\n}\n\nexport interface PerformanceAnalysis {\n totalRequests: number\n successful: number\n failed: number\n successRate: number\n responseTimes: {\n average: number\n median: number\n p95: number\n fastest: number\n slowest: number\n }\n errors: {\n rateLimit: number\n authentication: number\n network: number\n other: number\n }\n}\n\nexport interface ModelAnalysis {\n models: Record<string, {\n calls: number\n totalCost: number\n avgCostPerCall: number\n avgResponseTime: number\n successRate: number\n totalTokens: number\n }>\n}\n\nexport interface AggregationAnalysis {\n aggregations: Record<string, {\n calls: number\n totalCost: number\n avgCostPerCall: number\n successRate: number\n totalTokens: number\n }>\n}\n\nexport interface TrendsAnalysis {\n usageTrends: {\n dailyAverageCalls: number\n peakUsage: { date: string; calls: number }\n growthRate: number\n }\n costTrends: {\n dailyAverageCost: number\n peakCost: { date: string; cost: number }\n costPerCallTrend: 'increasing' | 'decreasing' | 'stable'\n }\n performanceTrends: {\n responseTimeTrend: 'improving' | 'degrading' | 'stable'\n errorRateTrend: 'improving' | 'degrading' | 'stable'\n successRateTrend: 'improving' | 'degrading' | 'stable'\n }\n}\n\nexport interface OptimizationRecommendations {\n costOptimization: string[]\n performanceOptimization: string[]\n usageOptimization: string[]\n}\n\nexport interface ResponseAnalysis {\n totalResponses: number\n averageResponseSize: number\n responseSizeDistribution: {\n small: number // < 500 bytes\n medium: number // 500-2000 bytes\n large: number // > 2000 bytes\n }\n contentPatterns: {\n averageResponseLength: number\n commonPhrases: string[]\n responseTypes: Record<string, number> // 'error', 'success', 'truncated'\n }\n errorAnalysis: {\n totalErrors: number\n errorTypes: Record<string, number>\n errorMessages: string[]\n successRate: number\n }\n modelResponseQuality: Record<string, {\n averageResponseLength: number\n averageResponseSize: number\n successRate: number\n commonErrors: string[]\n }>\n}\n\nexport interface ContentInsights {\n responsePatterns: {\n shortResponses: number // < 50 chars\n mediumResponses: number // 50-200 chars\n longResponses: number // > 200 chars\n }\n finishReasons: Record<string, number> // 'stop', 'length', 'content_filter'\n responseQuality: {\n averageTokensPerCharacter: number\n responseEfficiency: number // output tokens / response size\n }\n commonErrors: {\n rateLimit: number\n authentication: number\n modelNotFound: number\n other: number\n }\n}\n\nexport class Analytics {\n private spans: Span[] = []\n private spansPath: string\n\n constructor(spansPath?: string) {\n this.spansPath = spansPath || join(process.cwd(), '.spinal', 'spans.jsonl')\n }\n\n private loadSpans(): Span[] {\n try {\n const raw = readFileSync(this.spansPath, 'utf8')\n const lines = raw.trim().length ? raw.trim().split('\\n') : []\n \n const spans: Span[] = []\n for (const line of lines) {\n try {\n const span = JSON.parse(line)\n spans.push(span)\n } catch {\n // Ignore malformed JSON lines\n }\n }\n \n return spans\n } catch {\n return []\n }\n }\n\n private filterSpansByTime(spans: Span[], since?: string): Span[] {\n if (!since) return spans\n\n const now = Date.now()\n const timeMap: Record<string, number> = {\n '1h': 60 * 60 * 1000,\n '24h': 24 * 60 * 60 * 1000,\n '7d': 7 * 24 * 60 * 60 * 1000,\n '30d': 30 * 24 * 60 * 60 * 1000,\n '90d': 90 * 24 * 60 * 60 * 1000,\n '1y': 365 * 24 * 60 * 60 * 1000\n }\n\n const cutoff = now - (timeMap[since] || 0)\n \n return spans.filter(span => {\n const spanTime = span.start_time[0] * 1000 + span.start_time[1] / 1000000\n return spanTime >= cutoff\n })\n }\n\n private isOpenAISpan(span: Span): boolean {\n return span.name === 'openai-api-call' || \n span.attributes?.['spinal.provider'] === 'openai' ||\n span.instrumentation_info?.name === 'spinal-openai'\n }\n\n private getSpanDuration(span: Span): number {\n const start = span.start_time[0] * 1000 + span.start_time[1] / 1000000\n const end = span.end_time[0] * 1000 + span.end_time[1] / 1000000\n return end - start\n }\n\n public analyzeCosts(options: AnalyticsOptions = {}): CostAnalysis {\n this.spans = this.loadSpans()\n const filteredSpans = this.filterSpansByTime(this.spans, options.since)\n const openAISpans = filteredSpans.filter(span => this.isOpenAISpan(span))\n\n let totalCost = 0\n const totalCalls = openAISpans.length\n const costByModel: Record<string, { cost: number; calls: number; percentage: number }> = {}\n const costByAggregation: Record<string, { cost: number; calls: number; percentage: number }> = {}\n\n for (const span of openAISpans) {\n const attrs = span.attributes || {}\n const inputTokens = Number(attrs['spinal.input_tokens'] || 0)\n const outputTokens = Number(attrs['spinal.output_tokens'] || 0)\n const model = String(attrs['spinal.model'] || 'openai:gpt-4o-mini')\n const aggregationId = String(attrs['spinal.aggregation_id'] || 'unknown')\n\n const cost = estimateCost({ model, inputTokens, outputTokens })\n totalCost += cost\n\n // Group by model\n if (!costByModel[model]) {\n costByModel[model] = { cost: 0, calls: 0, percentage: 0 }\n }\n costByModel[model].cost += cost\n costByModel[model].calls += 1\n\n // Group by aggregation\n if (!costByAggregation[aggregationId]) {\n costByAggregation[aggregationId] = { cost: 0, calls: 0, percentage: 0 }\n }\n costByAggregation[aggregationId].cost += cost\n costByAggregation[aggregationId].calls += 1\n }\n\n // Calculate percentages\n Object.values(costByModel).forEach(model => {\n model.percentage = totalCost > 0 ? (model.cost / totalCost) * 100 : 0\n })\n\n Object.values(costByAggregation).forEach(agg => {\n agg.percentage = totalCost > 0 ? (agg.cost / totalCost) * 100 : 0\n })\n\n // Calculate cost trends (simplified - daily buckets)\n const costTrends = this.calculateCostTrends(filteredSpans)\n\n return {\n totalCost,\n totalCalls,\n averageCostPerCall: totalCalls > 0 ? totalCost / totalCalls : 0,\n costByModel,\n costByAggregation,\n costTrends\n }\n }\n\n public analyzeUsage(options: AnalyticsOptions = {}): UsageAnalysis {\n this.spans = this.loadSpans()\n const filteredSpans = this.filterSpansByTime(this.spans, options.since)\n const openAISpans = filteredSpans.filter(span => this.isOpenAISpan(span))\n\n const totalCalls = openAISpans.length\n let totalTokens = 0\n let inputTokens = 0\n let outputTokens = 0\n const usageByModel: Record<string, { calls: number; tokens: number; percentage: number }> = {}\n const usageByAggregation: Record<string, { calls: number; tokens: number; percentage: number }> = {}\n\n for (const span of openAISpans) {\n const attrs = span.attributes || {}\n const spanInputTokens = Number(attrs['spinal.input_tokens'] || 0)\n const spanOutputTokens = Number(attrs['spinal.output_tokens'] || 0)\n const spanTotalTokens = Number(attrs['spinal.total_tokens'] || 0)\n const model = String(attrs['spinal.model'] || 'openai:gpt-4o-mini')\n const aggregationId = String(attrs['spinal.aggregation_id'] || 'unknown')\n\n inputTokens += spanInputTokens\n outputTokens += spanOutputTokens\n totalTokens += spanTotalTokens\n\n // Group by model\n if (!usageByModel[model]) {\n usageByModel[model] = { calls: 0, tokens: 0, percentage: 0 }\n }\n usageByModel[model].calls += 1\n usageByModel[model].tokens += spanTotalTokens\n\n // Group by aggregation\n if (!usageByAggregation[aggregationId]) {\n usageByAggregation[aggregationId] = { calls: 0, tokens: 0, percentage: 0 }\n }\n usageByAggregation[aggregationId].calls += 1\n usageByAggregation[aggregationId].tokens += spanTotalTokens\n }\n\n // Calculate percentages\n Object.values(usageByModel).forEach(model => {\n model.percentage = totalCalls > 0 ? (model.calls / totalCalls) * 100 : 0\n })\n\n Object.values(usageByAggregation).forEach(agg => {\n agg.percentage = totalCalls > 0 ? (agg.calls / totalCalls) * 100 : 0\n })\n\n return {\n totalCalls,\n totalTokens,\n inputTokens,\n outputTokens,\n usageByModel,\n usageByAggregation,\n tokenEfficiency: {\n averageInputTokensPerCall: totalCalls > 0 ? inputTokens / totalCalls : 0,\n averageOutputTokensPerCall: totalCalls > 0 ? outputTokens / totalCalls : 0,\n tokenRatio: inputTokens > 0 ? outputTokens / inputTokens : 0\n }\n }\n }\n\n public analyzePerformance(options: AnalyticsOptions = {}): PerformanceAnalysis {\n this.spans = this.loadSpans()\n const filteredSpans = this.filterSpansByTime(this.spans, options.since)\n const openAISpans = filteredSpans.filter(span => this.isOpenAISpan(span))\n\n const totalRequests = openAISpans.length\n const successful = openAISpans.filter(span => span.status.code === 1).length\n const failed = totalRequests - successful\n const successRate = totalRequests > 0 ? (successful / totalRequests) * 100 : 0\n\n const responseTimes = openAISpans.map(span => this.getSpanDuration(span)).sort((a, b) => a - b)\n const average = responseTimes.length > 0 ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length : 0\n const median = responseTimes.length > 0 ? responseTimes[Math.floor(responseTimes.length / 2)] : 0\n const p95 = responseTimes.length > 0 ? responseTimes[Math.floor(responseTimes.length * 0.95)] : 0\n const fastest = responseTimes.length > 0 ? responseTimes[0] : 0\n const slowest = responseTimes.length > 0 ? responseTimes[responseTimes.length - 1] : 0\n\n // Simplified error analysis (would need more detailed error tracking)\n const errors = {\n rateLimit: 0,\n authentication: 0,\n network: 0,\n other: failed\n }\n\n return {\n totalRequests,\n successful,\n failed,\n successRate,\n responseTimes: {\n average,\n median,\n p95,\n fastest,\n slowest\n },\n errors\n }\n }\n\n public analyzeModels(options: AnalyticsOptions = {}): ModelAnalysis {\n this.spans = this.loadSpans()\n const filteredSpans = this.filterSpansByTime(this.spans, options.since)\n const openAISpans = filteredSpans.filter(span => this.isOpenAISpan(span))\n\n const models: Record<string, {\n calls: number\n totalCost: number\n avgCostPerCall: number\n avgResponseTime: number\n successRate: number\n totalTokens: number\n }> = {}\n\n for (const span of openAISpans) {\n const attrs = span.attributes || {}\n const model = String(attrs['spinal.model'] || 'openai:gpt-4o-mini')\n const inputTokens = Number(attrs['spinal.input_tokens'] || 0)\n const outputTokens = Number(attrs['spinal.output_tokens'] || 0)\n const totalTokens = Number(attrs['spinal.total_tokens'] || 0)\n const cost = estimateCost({ model, inputTokens, outputTokens })\n const responseTime = this.getSpanDuration(span)\n const isSuccess = span.status.code === 1\n\n if (!models[model]) {\n models[model] = {\n calls: 0,\n totalCost: 0,\n avgCostPerCall: 0,\n avgResponseTime: 0,\n successRate: 0,\n totalTokens: 0\n }\n }\n\n models[model].calls += 1\n models[model].totalCost += cost\n models[model].totalTokens += totalTokens\n models[model].avgResponseTime = (models[model].avgResponseTime * (models[model].calls - 1) + responseTime) / models[model].calls\n models[model].avgCostPerCall = models[model].totalCost / models[model].calls\n models[model].successRate = ((models[model].successRate * (models[model].calls - 1)) + (isSuccess ? 100 : 0)) / models[model].calls\n }\n\n return { models }\n }\n\n public analyzeAggregations(options: AnalyticsOptions = {}): AggregationAnalysis {\n this.spans = this.loadSpans()\n const filteredSpans = this.filterSpansByTime(this.spans, options.since)\n const openAISpans = filteredSpans.filter(span => this.isOpenAISpan(span))\n\n const aggregations: Record<string, {\n calls: number\n totalCost: number\n avgCostPerCall: number\n successRate: number\n totalTokens: number\n }> = {}\n\n for (const span of openAISpans) {\n const attrs = span.attributes || {}\n const aggregationId = String(attrs['spinal.aggregation_id'] || 'unknown')\n const inputTokens = Number(attrs['spinal.input_tokens'] || 0)\n const outputTokens = Number(attrs['spinal.output_tokens'] || 0)\n const totalTokens = Number(attrs['spinal.total_tokens'] || 0)\n const model = String(attrs['spinal.model'] || 'openai:gpt-4o-mini')\n const cost = estimateCost({ model, inputTokens, outputTokens })\n const isSuccess = span.status.code === 1\n\n if (!aggregations[aggregationId]) {\n aggregations[aggregationId] = {\n calls: 0,\n totalCost: 0,\n avgCostPerCall: 0,\n successRate: 0,\n totalTokens: 0\n }\n }\n\n aggregations[aggregationId].calls += 1\n aggregations[aggregationId].totalCost += cost\n aggregations[aggregationId].totalTokens += totalTokens\n aggregations[aggregationId].avgCostPerCall = aggregations[aggregationId].totalCost / aggregations[aggregationId].calls\n aggregations[aggregationId].successRate = ((aggregations[aggregationId].successRate * (aggregations[aggregationId].calls - 1)) + (isSuccess ? 100 : 0)) / aggregations[aggregationId].calls\n }\n\n return { aggregations }\n }\n\n public analyzeTrends(options: AnalyticsOptions = {}): TrendsAnalysis {\n this.spans = this.loadSpans()\n const filteredSpans = this.filterSpansByTime(this.spans, options.since)\n const openAISpans = filteredSpans.filter(span => this.isOpenAISpan(span))\n\n // Simplified trends calculation\n const dailyCalls = openAISpans.length / 30 // Assuming 30 days if no specific period\n const peakUsage = { date: 'unknown', calls: Math.max(...openAISpans.map(() => 1)) }\n const growthRate = 0 // Would need historical data for accurate calculation\n\n const totalCost = openAISpans.reduce((total, span) => {\n const attrs = span.attributes || {}\n const inputTokens = Number(attrs['spinal.input_tokens'] || 0)\n const outputTokens = Number(attrs['spinal.output_tokens'] || 0)\n const model = String(attrs['spinal.model'] || 'openai:gpt-4o-mini')\n return total + estimateCost({ model, inputTokens, outputTokens })\n }, 0)\n\n const dailyCost = totalCost / 30\n const peakCost = { date: 'unknown', cost: totalCost }\n const costPerCallTrend: 'increasing' | 'decreasing' | 'stable' = 'stable'\n\n return {\n usageTrends: {\n dailyAverageCalls: dailyCalls,\n peakUsage,\n growthRate\n },\n costTrends: {\n dailyAverageCost: dailyCost,\n peakCost,\n costPerCallTrend\n },\n performanceTrends: {\n responseTimeTrend: 'stable',\n errorRateTrend: 'stable',\n successRateTrend: 'stable'\n }\n }\n }\n\n public getOptimizationRecommendations(options: AnalyticsOptions = {}): OptimizationRecommendations {\n const costAnalysis = this.analyzeCosts(options)\n const usageAnalysis = this.analyzeUsage(options)\n const performanceAnalysis = this.analyzePerformance(options)\n const modelAnalysis = this.analyzeModels(options)\n\n const recommendations: OptimizationRecommendations = {\n costOptimization: [],\n performanceOptimization: [],\n usageOptimization: []\n }\n\n // Cost optimization recommendations\n if (costAnalysis.averageCostPerCall > 0.02) {\n recommendations.costOptimization.push('Consider using gpt-4o-mini for simple tasks to reduce costs')\n }\n\n if (costAnalysis.totalCost > 10) {\n recommendations.costOptimization.push('Monitor token usage and optimize prompts to reduce costs')\n }\n\n // Performance optimization recommendations\n if (performanceAnalysis.responseTimes.average > 3000) {\n recommendations.performanceOptimization.push('Consider implementing caching for repeated queries')\n }\n\n if (performanceAnalysis.successRate < 99) {\n recommendations.performanceOptimization.push('Monitor error rates and implement retry logic')\n }\n\n // Usage optimization recommendations\n const gpt4Usage = Object.entries(modelAnalysis.models).find(([model]) => model.includes('gpt-4o') && !model.includes('mini'))\n if (gpt4Usage && gpt4Usage[1].calls > usageAnalysis.totalCalls * 0.5) {\n recommendations.usageOptimization.push('Consider using gpt-4o-mini for simple tasks to reduce costs')\n }\n\n return recommendations\n }\n\n public analyzeResponses(options: AnalyticsOptions = {}): ResponseAnalysis {\n this.spans = this.loadSpans()\n const filteredSpans = this.filterSpansByTime(this.spans, options.since)\n const openAISpans = filteredSpans.filter(span => this.isOpenAISpan(span))\n\n let totalResponses = 0\n let totalResponseSize = 0\n const responseSizeDistribution = { small: 0, medium: 0, large: 0 }\n const responseTypes: Record<string, number> = { success: 0, error: 0, truncated: 0 }\n const errorTypes: Record<string, number> = {}\n const errorMessages: string[] = []\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const modelResponseQuality: Record<string, any> = {}\n const allResponseLengths: number[] = []\n\n for (const span of openAISpans) {\n const attrs = span.attributes || {}\n const responseData = attrs['spinal.response.binary_data']\n const responseSize = Number(attrs['spinal.response.size'] || 0)\n const model = String(attrs['spinal.model'] || 'unknown')\n const isSuccess = span.status.code === 1\n\n if (responseData) {\n