UNPKG

spinal-obs-node

Version:

WithSpinal cost-aware OpenTelemetry SDK for Node.js

1 lines • 100 kB
{"version":3,"sources":["../../src/cli/index.ts","../../src/runtime/config.ts","../../src/pricing/index.ts","../../src/cli/analytics.ts","../../src/analytics/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { Command } from 'commander'\nimport Conf from 'conf'\nimport open from 'open'\nimport { getConfig } from '../runtime/config'\nimport fs from 'fs'\nimport { estimateCost } from '../pricing'\nimport { createAnalyticsCommands } from './analytics'\n\nconst program = new Command()\nprogram.name('spinal').description('Spinal CLI').version('0.1.0')\n\nconst store = new Conf({ projectName: 'spinal' })\n\nprogram\n .command('status')\n .description('Show current mode and configuration summary')\n .action(async () => {\n const cfg = getConfig()\n const mode = cfg.mode\n const endpoint = cfg.endpoint\n const excluded = process.env.SPINAL_EXCLUDED_HOSTS ?? 'api.anthropic.com,api.azure.com'\n const excludeOpenAI = process.env.SPINAL_EXCLUDE_OPENAI === 'true'\n console.log(JSON.stringify({ mode, endpoint, localStorePath: cfg.localStorePath, excludedHosts: excluded, excludeOpenAI }, null, 2))\n })\n\nprogram\n .command('login')\n .description('Login for cloud mode (opens backend dashboard)')\n .option('--dashboard-url <url>', 'Backend dashboard URL', 'https://dashboard.withspinal.com/login')\n .action(async (opts) => {\n await open(opts.dashboardUrl)\n console.log('Opened browser for login. After obtaining an API key, set SPINAL_API_KEY.')\n })\n\nprogram\n .command('init')\n .description('Initialize configuration (optional)')\n .option('--endpoint <url>', 'Spinal endpoint', process.env.SPINAL_TRACING_ENDPOINT || 'https://cloud.withspinal.com')\n .action(async (opts) => {\n store.set('endpoint', opts.endpoint)\n console.log('Saved endpoint to local config.')\n })\n\nprogram\n .command('local')\n .description('Display local collected data in a readable format')\n .option('--limit <number>', 'Limit number of spans to display', '10')\n .option('--format <format>', 'Output format: table, json, or summary', 'table')\n .action(async (opts) => {\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 = parseInt(opts.limit, 10)\n const displaySpans = spans.slice(-limit) // Show most recent spans\n\n if (opts.format === 'json') {\n console.log(JSON.stringify(displaySpans, null, 2))\n } else if (opts.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\nprogram\n .command('report')\n .description('Summarize local usage and estimated costs')\n .option('--since <duration>', 'Time window, e.g., 24h', '24h')\n .action(async () => {\n const cfg = getConfig()\n if (cfg.mode !== 'local') {\n console.log('report is for local mode. Set SPINAL_MODE=local or omit SPINAL_API_KEY.')\n return\n }\n const file = cfg.localStorePath\n if (!fs.existsSync(file)) {\n console.log('No local data found.')\n return\n }\n const raw = await fs.promises.readFile(file, 'utf8')\n const lines = raw.trim().length ? raw.trim().split('\\n') : []\n let count = 0\n let est = 0\n for (const line of lines) {\n try {\n const span = JSON.parse(line)\n // Heuristic: look for attributes capturing token counts\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 est += estimateCost({ model, inputTokens, outputTokens })\n count++\n } catch {\n // Ignore malformed JSON lines\n }\n }\n console.log(JSON.stringify({ spansProcessed: count, estimatedCostUSD: Number(est.toFixed(4)) }, null, 2))\n })\n\n// Add analytics commands\nconst analyticsCommands = createAnalyticsCommands()\nanalyticsCommands.forEach(cmd => program.addCommand(cmd))\n\nprogram.parseAsync()\n\n\n","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","import { Command } from 'commander'\nimport { Analytics } from '../analytics'\nimport { getConfig } from '../runtime/config'\n\nexport function createAnalyticsCommands(): Command[] {\n const commands: Command[] = []\n\n // Cost Analysis Command\n const costCommand = new Command('cost')\n .description('Analyze OpenAI API costs')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '7d')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--by-model', 'Show breakdown by model')\n .option('--by-aggregation', 'Show breakdown by aggregation ID')\n .option('--trends', 'Show cost trends')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const analysis = analytics.analyzeCosts({\n since: options.since,\n format: options.format,\n byModel: options.byModel,\n byAggregation: options.byAggregation,\n trends: options.trends\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(analysis, null, 2))\n } else if (options.format === 'csv') {\n console.log('Period,Total Cost,Total Calls,Average Cost Per Call')\n console.log(`${options.since},${analysis.totalCost.toFixed(6)},${analysis.totalCalls},${analysis.averageCostPerCall.toFixed(6)}`)\n } else if (options.format === 'summary') {\n console.log(`šŸ’° Total Cost: $${analysis.totalCost.toFixed(4)}`)\n console.log(`šŸ“Š Total Calls: ${analysis.totalCalls}`)\n console.log(`šŸ“ˆ Average Cost per Call: $${analysis.averageCostPerCall.toFixed(6)}`)\n } else {\n // Table format\n console.log(`\\nšŸ’° Cost Analysis (Last ${options.since})\\n`)\n console.log('─'.repeat(60))\n console.log(`Total Cost: $${analysis.totalCost.toFixed(4)}`)\n console.log(`Total API Calls: ${analysis.totalCalls}`)\n console.log(`Average Cost per Call: $${analysis.averageCostPerCall.toFixed(6)}`)\n console.log('─'.repeat(60))\n\n if (options.byModel && Object.keys(analysis.costByModel).length > 0) {\n console.log('\\nšŸ“Š Cost by Model:')\n Object.entries(analysis.costByModel).forEach(([model, data]) => {\n console.log(`• ${model}: $${data.cost.toFixed(4)} (${data.calls} calls, ${data.percentage.toFixed(1)}%)`)\n })\n }\n\n if (options.byAggregation && Object.keys(analysis.costByAggregation).length > 0) {\n console.log('\\nšŸ·ļø Cost by Aggregation:')\n Object.entries(analysis.costByAggregation).forEach(([agg, data]) => {\n console.log(`• ${agg}: $${data.cost.toFixed(4)} (${data.calls} calls, ${data.percentage.toFixed(1)}%)`)\n })\n }\n\n if (options.trends && analysis.costTrends.length > 0) {\n console.log('\\nšŸ“ˆ Cost Trends:')\n analysis.costTrends.forEach(trend => {\n console.log(`• ${trend.date}: $${trend.cost.toFixed(4)} (${trend.calls} calls)`)\n })\n }\n }\n })\n\n // Usage Analytics Command\n const usageCommand = new Command('usage')\n .description('Analyze OpenAI API usage patterns')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '24h')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--tokens', 'Show token breakdown')\n .option('--by-model', 'Show usage by model')\n .option('--by-aggregation', 'Show usage by aggregation ID')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const analysis = analytics.analyzeUsage({\n since: options.since,\n format: options.format,\n byModel: options.byModel,\n byAggregation: options.byAggregation\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(analysis, null, 2))\n } else if (options.format === 'csv') {\n console.log('Period,Total Calls,Total Tokens,Input Tokens,Output Tokens')\n console.log(`${options.since},${analysis.totalCalls},${analysis.totalTokens},${analysis.inputTokens},${analysis.outputTokens}`)\n } else if (options.format === 'summary') {\n console.log(`šŸ“Š Total Calls: ${analysis.totalCalls}`)\n console.log(`šŸ”¤ Total Tokens: ${analysis.totalTokens.toLocaleString()}`)\n console.log(`šŸ“„ Input Tokens: ${analysis.inputTokens.toLocaleString()}`)\n console.log(`šŸ“¤ Output Tokens: ${analysis.outputTokens.toLocaleString()}`)\n } else {\n // Table format\n console.log(`\\nšŸ“Š Usage Analytics (Last ${options.since})\\n`)\n console.log('─'.repeat(60))\n console.log(`Total API Calls: ${analysis.totalCalls}`)\n console.log(`Total Tokens: ${analysis.totalTokens.toLocaleString()}`)\n console.log(`• Input tokens: ${analysis.inputTokens.toLocaleString()}`)\n console.log(`• Output tokens: ${analysis.outputTokens.toLocaleString()}`)\n console.log('─'.repeat(60))\n\n if (options.tokens) {\n console.log('\\nšŸ“ˆ Token Efficiency:')\n console.log(`• Average input tokens per call: ${analysis.tokenEfficiency.averageInputTokensPerCall.toFixed(1)}`)\n console.log(`• Average output tokens per call: ${analysis.tokenEfficiency.averageOutputTokensPerCall.toFixed(1)}`)\n console.log(`• Token ratio (output/input): ${analysis.tokenEfficiency.tokenRatio.toFixed(2)}`)\n }\n\n if (options.byModel && Object.keys(analysis.usageByModel).length > 0) {\n console.log('\\nšŸ¤– Usage by Model:')\n Object.entries(analysis.usageByModel).forEach(([model, data]) => {\n console.log(`• ${model}: ${data.calls} calls (${data.percentage.toFixed(1)}%), ${data.tokens.toLocaleString()} tokens`)\n })\n }\n\n if (options.byAggregation && Object.keys(analysis.usageByAggregation).length > 0) {\n console.log('\\nšŸ·ļø Usage by Aggregation:')\n Object.entries(analysis.usageByAggregation).forEach(([agg, data]) => {\n console.log(`• ${agg}: ${data.calls} calls (${data.percentage.toFixed(1)}%), ${data.tokens.toLocaleString()} tokens`)\n })\n }\n }\n })\n\n // Performance Analytics Command\n const performanceCommand = new Command('performance')\n .description('Analyze OpenAI API performance')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '7d')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--response-times', 'Show response time analysis')\n .option('--errors', 'Show error analysis')\n .option('--by-model', 'Show performance by model')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const analysis = analytics.analyzePerformance({\n since: options.since,\n format: options.format\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(analysis, null, 2))\n } else if (options.format === 'csv') {\n console.log('Period,Total Requests,Successful,Failed,Success Rate,Avg Response Time')\n console.log(`${options.since},${analysis.totalRequests},${analysis.successful},${analysis.failed},${analysis.successRate.toFixed(1)}%,${analysis.responseTimes.average.toFixed(1)}ms`)\n } else if (options.format === 'summary') {\n console.log(`⚔ Total Requests: ${analysis.totalRequests}`)\n console.log(`āœ… Successful: ${analysis.successful} (${analysis.successRate.toFixed(1)}%)`)\n console.log(`āŒ Failed: ${analysis.failed}`)\n console.log(`ā±ļø Avg Response Time: ${analysis.responseTimes.average.toFixed(1)}ms`)\n } else {\n // Table format\n console.log(`\\n⚔ Performance Analytics (Last ${options.since})\\n`)\n console.log('─'.repeat(60))\n console.log(`Total Requests: ${analysis.totalRequests}`)\n console.log(`Successful: ${analysis.successful} (${analysis.successRate.toFixed(1)}%)`)\n console.log(`Failed: ${analysis.failed} (${(100 - analysis.successRate).toFixed(1)}%)`)\n console.log('─'.repeat(60))\n\n if (options.responseTimes) {\n console.log('\\nā±ļø Response Times:')\n console.log(`• Average: ${analysis.responseTimes.average.toFixed(1)}ms`)\n console.log(`• Median: ${analysis.responseTimes.median.toFixed(1)}ms`)\n console.log(`• 95th percentile: ${analysis.responseTimes.p95.toFixed(1)}ms`)\n console.log(`• Fastest: ${analysis.responseTimes.fastest.toFixed(1)}ms`)\n console.log(`• Slowest: ${analysis.responseTimes.slowest.toFixed(1)}ms`)\n }\n\n if (options.errors && analysis.errors.other > 0) {\n console.log('\\n🚨 Error Analysis:')\n console.log(`• Rate limit errors: ${analysis.errors.rateLimit}`)\n console.log(`• Authentication errors: ${analysis.errors.authentication}`)\n console.log(`• Network errors: ${analysis.errors.network}`)\n console.log(`• Other errors: ${analysis.errors.other}`)\n }\n }\n })\n\n // Models Analytics Command\n const modelsCommand = new Command('models')\n .description('Compare performance and costs across different models')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '30d')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--efficiency', 'Show model efficiency metrics')\n .option('--costs', 'Show cost analysis by model')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const analysis = analytics.analyzeModels({\n since: options.since,\n format: options.format\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(analysis, null, 2))\n } else if (options.format === 'summary') {\n Object.entries(analysis.models).forEach(([model, data]) => {\n console.log(`${model}: ${data.calls} calls, $${data.totalCost.toFixed(4)}, ${data.successRate.toFixed(1)}% success`)\n })\n } else {\n // Table format\n console.log(`\\nšŸ¤– Model Analytics (Last ${options.since})\\n`)\n console.log('─'.repeat(80))\n \n Object.entries(analysis.models).forEach(([model, data]) => {\n console.log(`${model}:`)\n console.log(` • Calls: ${data.calls}`)\n console.log(` • Total cost: $${data.totalCost.toFixed(4)}`)\n console.log(` • Avg cost per call: $${data.avgCostPerCall.toFixed(6)}`)\n console.log(` • Avg response time: ${data.avgResponseTime.toFixed(1)}ms`)\n console.log(` • Success rate: ${data.successRate.toFixed(1)}%`)\n console.log(` • Total tokens: ${data.totalTokens.toLocaleString()}`)\n console.log('')\n })\n }\n })\n\n // Aggregations Analytics Command\n const aggregationsCommand = new Command('aggregations')\n .description('Analyze usage by custom aggregation IDs')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '7d')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--id <aggregationId>', 'Show specific aggregation ID')\n .option('--costs', 'Show cost analysis by aggregation')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const analysis = analytics.analyzeAggregations({\n since: options.since,\n format: options.format\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(analysis, null, 2))\n } else if (options.format === 'summary') {\n Object.entries(analysis.aggregations).forEach(([agg, data]) => {\n console.log(`${agg}: ${data.calls} calls, $${data.totalCost.toFixed(4)}, ${data.successRate.toFixed(1)}% success`)\n })\n } else {\n // Table format\n console.log(`\\nšŸ·ļø Aggregation Analytics (Last ${options.since})\\n`)\n console.log('─'.repeat(80))\n \n Object.entries(analysis.aggregations).forEach(([agg, data]) => {\n console.log(`${agg}:`)\n console.log(` • Calls: ${data.calls}`)\n console.log(` • Total cost: $${data.totalCost.toFixed(4)}`)\n console.log(` • Avg cost per call: $${data.avgCostPerCall.toFixed(6)}`)\n console.log(` • Success rate: ${data.successRate.toFixed(1)}%`)\n console.log(` • Total tokens: ${data.totalTokens.toLocaleString()}`)\n console.log('')\n })\n }\n })\n\n // Trends Analysis Command\n const trendsCommand = new Command('trends')\n .description('Identify usage patterns and trends over time')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '30d')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--costs', 'Show cost trends')\n .option('--usage', 'Show usage trends')\n .option('--performance', 'Show performance trends')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const analysis = analytics.analyzeTrends({\n since: options.since,\n format: options.format\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(analysis, null, 2))\n } else if (options.format === 'summary') {\n console.log(`šŸ“Š Daily avg calls: ${analysis.usageTrends.dailyAverageCalls.toFixed(1)}`)\n console.log(`šŸ’° Daily avg cost: $${analysis.costTrends.dailyAverageCost.toFixed(4)}`)\n console.log(`⚔ Performance: ${analysis.performanceTrends.responseTimeTrend}`)\n } else {\n // Table format\n console.log(`\\nšŸ“ˆ Trends Analysis (Last ${options.since})\\n`)\n console.log('─'.repeat(60))\n\n if (options.usage || !options.costs && !options.performance) {\n console.log('\\nšŸ“Š Usage Trends:')\n console.log(`• Daily average calls: ${analysis.usageTrends.dailyAverageCalls.toFixed(1)}`)\n console.log(`• Peak usage: ${analysis.usageTrends.peakUsage.calls} calls (${analysis.usageTrends.peakUsage.date})`)\n console.log(`• Growth rate: ${analysis.usageTrends.growthRate > 0 ? '+' : ''}${analysis.usageTrends.growthRate.toFixed(1)}% week-over-week`)\n }\n\n if (options.costs || !options.usage && !options.performance) {\n console.log('\\nšŸ’° Cost Trends:')\n console.log(`• Daily average cost: $${analysis.costTrends.dailyAverageCost.toFixed(4)}`)\n console.log(`• Peak cost: $${analysis.costTrends.peakCost.cost.toFixed(4)} (${analysis.costTrends.peakCost.date})`)\n console.log(`• Cost per call trend: ${analysis.costTrends.costPerCallTrend}`)\n }\n\n if (options.performance || !options.usage && !options.costs) {\n console.log('\\n⚔ Performance Trends:')\n console.log(`• Response time trend: ${analysis.performanceTrends.responseTimeTrend}`)\n console.log(`• Error rate trend: ${analysis.performanceTrends.errorRateTrend}`)\n console.log(`• Success rate trend: ${analysis.performanceTrends.successRateTrend}`)\n }\n }\n })\n\n // Optimization Recommendations Command\n const optimizeCommand = new Command('optimize')\n .description('Get actionable recommendations to optimize costs and performance')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '7d')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--costs', 'Show cost optimization recommendations')\n .option('--performance', 'Show performance optimization recommendations')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const recommendations = analytics.getOptimizationRecommendations({\n since: options.since,\n format: options.format\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(recommendations, null, 2))\n } else if (options.format === 'summary') {\n const allRecs = [\n ...recommendations.costOptimization,\n ...recommendations.performanceOptimization,\n ...recommendations.usageOptimization\n ]\n allRecs.forEach(rec => console.log(`• ${rec}`))\n } else {\n // Table format\n console.log(`\\nšŸ’” Optimization Recommendations (Last ${options.since})\\n`)\n console.log('─'.repeat(60))\n\n if (options.costs || (!options.performance && recommendations.costOptimization.length > 0)) {\n console.log('\\nšŸ’° Cost Optimization:')\n recommendations.costOptimization.forEach(rec => {\n console.log(`• ${rec}`)\n })\n }\n\n if (options.performance || (!options.costs && recommendations.performanceOptimization.length > 0)) {\n console.log('\\n⚔ Performance Optimization:')\n recommendations.performanceOptimization.forEach(rec => {\n console.log(`• ${rec}`)\n })\n }\n\n if (!options.costs && !options.performance && recommendations.usageOptimization.length > 0) {\n console.log('\\nšŸŽÆ Usage Optimization:')\n recommendations.usageOptimization.forEach(rec => {\n console.log(`• ${rec}`)\n })\n }\n\n if (recommendations.costOptimization.length === 0 && \n recommendations.performanceOptimization.length === 0 && \n recommendations.usageOptimization.length === 0) {\n console.log('\\nāœ… No specific optimization recommendations at this time.')\n console.log('Your OpenAI API usage appears to be well-optimized!')\n }\n }\n })\n\n // Response Analysis Command\n const responsesCommand = new Command('responses')\n .description('Analyze OpenAI API response content and quality')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '7d')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--errors', 'Show detailed error analysis')\n .option('--by-model', 'Show response quality by model')\n .option('--size-distribution', 'Show response size distribution')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const analysis = analytics.analyzeResponses({\n since: options.since,\n format: options.format\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(analysis, null, 2))\n } else if (options.format === 'csv') {\n console.log('Period,Total Responses,Avg Size,Success Rate,Error Rate')\n console.log(`${options.since},${analysis.totalResponses},${analysis.averageResponseSize.toFixed(1)},${analysis.errorAnalysis.successRate.toFixed(1)}%,${analysis.errorAnalysis.totalErrors}`)\n } else if (options.format === 'summary') {\n console.log(`šŸ“„ Total Responses: ${analysis.totalResponses}`)\n console.log(`šŸ“ Avg Response Size: ${analysis.averageResponseSize.toFixed(1)} bytes`)\n console.log(`āœ… Success Rate: ${analysis.errorAnalysis.successRate.toFixed(1)}%`)\n console.log(`āŒ Total Errors: ${analysis.errorAnalysis.totalErrors}`)\n } else {\n // Table format\n console.log(`\\nšŸ“„ Response Analysis (Last ${options.since})\\n`)\n console.log('─'.repeat(60))\n console.log(`Total Responses: ${analysis.totalResponses}`)\n console.log(`Average Response Size: ${analysis.averageResponseSize.toFixed(1)} bytes`)\n console.log(`Success Rate: ${analysis.errorAnalysis.successRate.toFixed(1)}%`)\n console.log(`Error Rate: ${analysis.errorAnalysis.totalErrors > 0 ? ((analysis.errorAnalysis.totalErrors / analysis.totalResponses) * 100).toFixed(1) : 0}%`)\n console.log('─'.repeat(60))\n\n if (options.sizeDistribution) {\n console.log('\\nšŸ“Š Response Size Distribution:')\n console.log(`• Small (< 500 bytes): ${analysis.responseSizeDistribution.small} responses`)\n console.log(`• Medium (500-2000 bytes): ${analysis.responseSizeDistribution.medium} responses`)\n console.log(`• Large (> 2000 bytes): ${analysis.responseSizeDistribution.large} responses`)\n }\n\n if (options.errors && analysis.errorAnalysis.totalErrors > 0) {\n console.log('\\n🚨 Error Analysis:')\n Object.entries(analysis.errorAnalysis.errorTypes).forEach(([errorType, count]) => {\n console.log(`• ${errorType}: ${count} occurrences`)\n })\n \n if (analysis.errorAnalysis.errorMessages.length > 0) {\n console.log('\\nšŸ“ Recent Error Messages:')\n analysis.errorAnalysis.errorMessages.slice(0, 5).forEach(msg => {\n console.log(`• ${msg}`)\n })\n }\n }\n\n if (options.byModel && Object.keys(analysis.modelResponseQuality).length > 0) {\n console.log('\\nšŸ¤– Response Quality by Model:')\n Object.entries(analysis.modelResponseQuality).forEach(([model, data]) => {\n console.log(`${model}:`)\n console.log(` • Avg response length: ${data.averageResponseLength.toFixed(1)} chars`)\n console.log(` • Avg response size: ${data.averageResponseSize.toFixed(1)} bytes`)\n console.log(` • Success rate: ${data.successRate.toFixed(1)}%`)\n console.log('')\n })\n }\n }\n })\n\n // Content Insights Command\n const contentCommand = new Command('content')\n .description('Get insights about response content patterns and quality')\n .option('--since <period>', 'Time period (1h, 24h, 7d, 30d, 90d, 1y)', '7d')\n .option('--format <format>', 'Output format (table, json, csv, summary)', 'table')\n .option('--patterns', 'Show response length patterns')\n .option('--finish-reasons', 'Show finish reason distribution')\n .option('--quality', 'Show response quality metrics')\n .action(async (options) => {\n const config = getConfig()\n const analytics = new Analytics(config.localStorePath)\n const insights = analytics.getContentInsights({\n since: options.since,\n format: options.format\n })\n\n if (options.format === 'json') {\n console.log(JSON.stringify(insights, null, 2))\n } else if (options.format === 'summary') {\n console.log(`šŸ“Š Response Patterns: ${insights.responsePatterns.shortResponses + insights.responsePatterns.mediumResponses + insights.responsePatterns.longResponses} total`)\n console.log(`šŸŽÆ Avg tokens/char: ${insights.responseQuality.averageTokensPerCharacter.toFixed(2)}`)\n console.log(`⚔ Response efficiency: ${insights.responseQuality.responseEfficiency.toFixed(4)}`)\n } else {\n // Table format\n console.log(`\\nšŸ“ Content Insights (Last ${options.since})\\n`)\n console.log('─'.repeat(60))\n\n if (options.patterns || (!options.finishReasons && !options.quality)) {\n console.log('\\nšŸ“Š Response Length Patterns:')\n const total = insights.responsePatterns.shortResponses + insights.responsePatterns.mediumResponses + insights.responsePatterns.longResponses\n console.log(`• Short responses (< 50 chars): ${insights.responsePatterns.shortResponses} (${total > 0 ? (insights.responsePatterns.shortResponses / total * 100).toFixed(1) : 0}%)`)\n console.log(`• Medium responses (50-200 chars): ${insights.responsePatterns.mediumResponses} (${total > 0 ? (insights.responsePatterns.mediumResponses / total * 100).toFixed(1) : 0}%)`)\n console.log(`• Long responses (> 200 chars): ${insights.responsePatterns.longResponses} (${total > 0 ? (insights.responsePatterns.longResponses / total * 100).toFixed(1) : 0}%)`)\n }\n\n if (options.finishReasons || (!options.patterns && !options.quality)) {\n console.log('\\nšŸŽÆ Finish Reasons:')\n Object.entries(insights.finishReasons).forEach(([reason, count]) => {\n console.log(`• ${reason}: ${count} responses`)\n })\n }\n\n if (options.quality || (!options.patterns && !options.finishReasons)) {\n console.log('\\n⚔ Response Quality Metrics:')\n console.log(`• Average tokens per character: ${insights.responseQuality.averageTokensPerCharacter.toFixed(2)}`)\n console.log(`• Response efficiency (tokens/byte): ${insights.responseQuality.responseEfficiency.toFixed(4)}`)\n }\n\n if (Object.values(insights.commonErrors).some(count => count > 0)) {\n console.log('\\n🚨 Common Error Types:')\n Object.entries(insights.commonErrors).forEach(([errorType, count]) => {\n if (count > 0) {\n console.log(`• ${errorType}: ${count} occurrences`)\n }\n })\n }\n }\n })\n\n commands.push(\n costCommand,\n usageCommand,\n performanceCommand,\n modelsCommand,\n aggregationsCommand,\n trendsCommand,\n optimizeCommand,\n responsesCommand,\n contentCommand\n )\n\n return commands\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