From c8713d488c109eee525a89cc08ddecbc2ca2488d Mon Sep 17 00:00:00 2001 From: Kenny Heitritter Date: Fri, 6 Feb 2026 22:48:44 -0600 Subject: [PATCH 01/13] fix: Update branding script for new logo.ts structure, add LOGO constant --- branding/apply.ts | 56 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/branding/apply.ts b/branding/apply.ts index f9d5f84660c..f0b097f725e 100644 --- a/branding/apply.ts +++ b/branding/apply.ts @@ -220,33 +220,65 @@ interface FileTransform { } const FILE_TRANSFORMS: FileTransform[] = [ - // CLI UI logo with purple Q + // CLI logo.ts - update the logo export + { + pattern: "packages/opencode/src/cli/logo.ts", + transform: (content, config) => { + // Replace the logo export with qbraid logo + const leftStr = config.logo.tui.left.map((l) => `"${l}"`).join(", ") + const rightStr = config.logo.tui.right.map((l) => `"${l}"`).join(", ") + + return `export const logo = { + left: [${leftStr}], + right: [${rightStr}], +} + +export const marks = "_^~" +` + }, + }, + + // CLI UI logo() function - update to render Q in purple { pattern: "packages/opencode/src/cli/ui.ts", transform: (content, config) => { const logoStr = config.logo.cli.map((row) => ` [\`${row[0]}\`, \`${row[1]}\`],`).join("\n") - let result = content.replace(/const LOGO = \[\n[\s\S]*?\n \]/, `const LOGO = [\n${logoStr}\n ]`) + // Add LOGO constant and update logo() function to use it with purple Q + let result = content.replace( + /import \{ logo as glyphs \} from "\.\/logo"/, + `import { logo as glyphs } from "./logo" + +const LOGO = [ +${logoStr} + ]` + ) - // Replace the logo() function to render the Q in purple - // Purple ANSI: \x1b[35m (standard) or \x1b[38;2;147;112;219m (RGB for medium purple) + // Replace the logo() function to use LOGO with purple Q rendering result = result.replace( - /export function logo\(pad\?: string\) \{[\s\S]*?\n \}/, + /export function logo\(pad\?: string\) \{[\s\S]*?return result\.join\(""\)\.trimEnd\(\)\n \}/, `export function logo(pad?: string) { - const PURPLE = "\\x1b[38;2;147;112;219m" // Medium purple RGB - const result = [] + const result: string[] = [] + const reset = "\\x1b[0m" + const left = { + fg: Bun.color("gray", "ansi") ?? "", + shadow: "\\x1b[38;5;235m", + bg: "\\x1b[48;5;235m", + } + const PURPLE = "\\x1b[38;2;147;112;219m" // Medium purple RGB for Q + for (const row of LOGO) { if (pad) result.push(pad) - result.push(Bun.color("gray", "ansi")) + result.push(left.fg) result.push(row[0]) - result.push("\\x1b[0m") - result.push(PURPLE) // Purple tint for the Q + result.push(reset) + result.push(PURPLE) // Purple for the Q result.push(row[1]) - result.push("\\x1b[0m") + result.push(reset) result.push(EOL) } return result.join("").trimEnd() - }`, + }` ) return result From 30f2595851bce54450197c74675546e307acfc52 Mon Sep 17 00:00:00 2001 From: Kenny Heitritter Date: Fri, 6 Feb 2026 22:53:20 -0600 Subject: [PATCH 02/13] fix: Properly exclude @gitlab/opencode-gitlab-auth from branding rename --- branding/apply.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/branding/apply.ts b/branding/apply.ts index f0b097f725e..6f5a1c128d8 100644 --- a/branding/apply.ts +++ b/branding/apply.ts @@ -124,11 +124,12 @@ function buildReplacements(config: Branding): Replacement[] { // Product name replacements (case-sensitive) // Use negative lookbehind/lookahead to avoid matching: - // - @opencode-ai package names - // - Directory paths like /opencode/ (but allow /bin/opencode at end of path) + // - @opencode-ai package names (not preceded by @) + // - Directory paths like /opencode/ or @gitlab/opencode (not preceded by /) // - File extensions like opencode.json + // - Third-party packages like @gitlab/opencode-gitlab-auth replacements.push({ - search: /(? ${r.productName}`, }) From fc970e8474b1ab63677177838cf5ebab05fcdc9a Mon Sep 17 00:00:00 2001 From: Kenny Heitritter Date: Sat, 7 Feb 2026 13:16:42 -0600 Subject: [PATCH 03/13] fix: Revert overly-broad lookbehind that broke binary naming The (? ${r.productName}`, }) From bae073281b0568b6bf1cbd4efc8a506d4b758de3 Mon Sep 17 00:00:00 2001 From: Kenny Heitritter Date: Sat, 7 Feb 2026 13:29:22 -0600 Subject: [PATCH 04/13] fix: Move LOGO constant inside logo() to fix namespace IIFE scoping The UI namespace compiles to an IIFE in the bundled binary. Module-scope const declarations (like LOGO) are not accessible inside the IIFE, causing 'LOGO is not defined' at runtime when the logo() function is called (e.g. via codeq -s ). Fix by defining LOGO as a local constant inside the logo() function rather than injecting it at module scope outside the namespace. --- branding/apply.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/branding/apply.ts b/branding/apply.ts index 343b2db5410..b5827e9083f 100644 --- a/branding/apply.ts +++ b/branding/apply.ts @@ -240,25 +240,21 @@ export const marks = "_^~" }, // CLI UI logo() function - update to render Q in purple + // IMPORTANT: LOGO must be defined INSIDE the logo() function, not at module scope. + // The UI namespace compiles to an IIFE in the bundled binary, and module-scope + // const declarations are not accessible inside the IIFE. { pattern: "packages/opencode/src/cli/ui.ts", transform: (content, config) => { const logoStr = config.logo.cli.map((row) => ` [\`${row[0]}\`, \`${row[1]}\`],`).join("\n") - // Add LOGO constant and update logo() function to use it with purple Q - let result = content.replace( - /import \{ logo as glyphs \} from "\.\/logo"/, - `import { logo as glyphs } from "./logo" - -const LOGO = [ -${logoStr} - ]` - ) - - // Replace the logo() function to use LOGO with purple Q rendering - result = result.replace( + // Replace the logo() function with LOGO defined as a local constant inside it + const result = content.replace( /export function logo\(pad\?: string\) \{[\s\S]*?return result\.join\(""\)\.trimEnd\(\)\n \}/, `export function logo(pad?: string) { + const LOGO = [ +${logoStr} + ] const result: string[] = [] const reset = "\\x1b[0m" const left = { From 8ce5643031e0452cd571a962220a3ca78645d387 Mon Sep 17 00:00:00 2001 From: Kenny Heitritter Date: Fri, 23 Jan 2026 23:41:48 -0500 Subject: [PATCH 05/13] feat(telemetry): add CodeQ telemetry collection system Add qBraid-specific telemetry module to collect session data for analysis and model improvement. Components: - config: Add qbraid.telemetry config section under a separate namespace to maintain upstream compatibility when merging from opencode - types: TypeScript types matching the telemetry service schema - sanitizer: Strip secrets, redact sensitive files, truncate large content - signals: Track implicit feedback (retries, errors, abandonment) - uploader: Batch and upload data with retry logic and offline handling - consent: Tier-based consent (free=forced opt-in, paid=opt-in) - collector: Main module that coordinates all components Design choices for upstream compatibility: - All telemetry code is in a separate src/telemetry/ directory - Config uses qbraid.* namespace that upstream will ignore - No modifications to existing session/processor logic yet Implements: [CodeQ] Add telemetry config schema Implements: [CodeQ] Implement TelemetryCollector module Implements: [CodeQ] Implement content sanitizer Implements: [CodeQ] Implement batch uploader with retry --- packages/opencode/src/config/config.ts | 48 ++ packages/opencode/src/telemetry/collector.ts | 446 +++++++++++++++++++ packages/opencode/src/telemetry/consent.ts | 170 +++++++ packages/opencode/src/telemetry/index.ts | 196 ++++++++ packages/opencode/src/telemetry/sanitizer.ts | 205 +++++++++ packages/opencode/src/telemetry/signals.ts | 128 ++++++ packages/opencode/src/telemetry/types.ts | 188 ++++++++ packages/opencode/src/telemetry/uploader.ts | 257 +++++++++++ 8 files changed, 1638 insertions(+) create mode 100644 packages/opencode/src/telemetry/collector.ts create mode 100644 packages/opencode/src/telemetry/consent.ts create mode 100644 packages/opencode/src/telemetry/index.ts create mode 100644 packages/opencode/src/telemetry/sanitizer.ts create mode 100644 packages/opencode/src/telemetry/signals.ts create mode 100644 packages/opencode/src/telemetry/types.ts create mode 100644 packages/opencode/src/telemetry/uploader.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6dd0592d51e..f62078a4a23 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1183,6 +1183,54 @@ export namespace Config { .describe("Timeout in milliseconds for model context protocol (MCP) requests"), }) .optional(), + // qBraid-specific configuration (CodeQ customizations) + // This section is ignored by upstream opencode and contains qBraid-specific features + qbraid: z + .object({ + telemetry: z + .object({ + enabled: z + .union([z.boolean(), z.literal("tier-default")]) + .optional() + .describe( + "Enable telemetry collection. 'tier-default' uses tier-based defaults (free=enabled, paid=disabled). Default: 'tier-default'", + ), + endpoint: z + .string() + .url() + .optional() + .describe("Telemetry service endpoint. Default: https://telemetry.qbraid.com"), + dataLevel: z + .enum(["full", "metrics-only"]) + .optional() + .describe( + "Level of data to collect. 'full' includes message content, 'metrics-only' only collects usage stats. Default: 'full'", + ), + excludePatterns: z + .array(z.string()) + .optional() + .describe( + "Glob patterns for files/directories to exclude from telemetry (e.g., ['**/secrets/**', '**/.env*'])", + ), + batchSize: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Number of turns to batch before uploading. Default: 5"), + flushIntervalMs: z + .number() + .int() + .min(1000) + .optional() + .describe("Maximum time (ms) to wait before flushing buffered data. Default: 30000"), + }) + .optional() + .describe("Telemetry settings for CodeQ session data collection"), + }) + .optional() + .describe("qBraid-specific configuration for CodeQ"), }) .strict() .meta({ diff --git a/packages/opencode/src/telemetry/collector.ts b/packages/opencode/src/telemetry/collector.ts new file mode 100644 index 00000000000..9958d69dc64 --- /dev/null +++ b/packages/opencode/src/telemetry/collector.ts @@ -0,0 +1,446 @@ +/** + * Telemetry Collector + * + * Main module that collects session telemetry by subscribing to the Event Bus. + * Aggregates data and coordinates with sanitizer, signals, and uploader modules. + */ + +import { Log } from "../util/log" +import { Bus } from "../bus" +import { Config } from "../config/config" +import { createSanitizer, hashFilePath, getFileExtension } from "./sanitizer" +import { createSignalTracker, type SignalTracker } from "./signals" +import { createUploader, type TelemetryUploader } from "./uploader" +import { getConsentStatus, getTelemetryEndpoint } from "./consent" +import type { + AssistantMessageData, + Environment, + FileChangeData, + ModelUsage, + SessionMetrics, + TelemetrySession, + TelemetryTurn, + ToolCallData, + UserMessageData, +} from "./types" + +const log = Log.create({ service: "telemetry:collector" }) + +// Package version (injected at build time or read from package.json) +const CODEQ_VERSION = process.env.npm_package_version ?? "0.0.0" + +/** + * State for tracking the current session + */ +interface SessionState { + sessionId: string + startedAt: Date + userId: string + organizationId: string + environment: Environment + metrics: SessionMetrics + modelUsage: ModelUsage + currentTurnIndex: number + currentTurn: Partial | null +} + +/** + * Telemetry collector instance + */ +export class TelemetryCollector { + private uploader: TelemetryUploader | null = null + private signalTracker: SignalTracker + private sanitizer: ReturnType + private sessionState: SessionState | null = null + private isEnabled = false + private authToken: string | null = null + private unsubscribers: (() => void)[] = [] + + constructor() { + this.signalTracker = createSignalTracker() + this.sanitizer = createSanitizer() + } + + /** + * Initialize the collector + */ + async initialize(authToken?: string): Promise { + this.authToken = authToken ?? null + + // Check consent + const consent = await getConsentStatus(authToken) + + if (!consent.telemetryEnabled) { + log.info("telemetry disabled by consent", { tier: consent.tier }) + this.isEnabled = false + return + } + + // Get config + const config = await Config.get() + const telemetryConfig = config.qbraid?.telemetry + + // Update sanitizer with exclude patterns from config + if (telemetryConfig?.excludePatterns) { + this.sanitizer = createSanitizer({ + excludePatterns: telemetryConfig.excludePatterns, + }) + } + + // Create uploader + const endpoint = telemetryConfig?.endpoint ?? getTelemetryEndpoint() + + if (authToken) { + this.uploader = createUploader({ + endpoint, + authToken, + batchSize: telemetryConfig?.batchSize, + flushIntervalMs: telemetryConfig?.flushIntervalMs, + }) + } + + this.isEnabled = true + log.info("telemetry initialized", { endpoint, dataLevel: consent.dataLevel }) + + // Subscribe to events + this.subscribeToEvents() + } + + /** + * Start collecting for a new session + */ + async startSession(sessionId: string, userId: string, organizationId: string): Promise { + if (!this.isEnabled) return + + const consent = await getConsentStatus(this.authToken ?? undefined) + + this.sessionState = { + sessionId, + startedAt: new Date(), + userId, + organizationId, + environment: this.detectEnvironment(), + metrics: { + turnCount: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + toolCallCount: 0, + toolErrorCount: 0, + filesModified: 0, + linesAdded: 0, + linesDeleted: 0, + }, + modelUsage: {}, + currentTurnIndex: 0, + currentTurn: null, + } + + this.signalTracker.reset() + + // Create session on the service + if (this.uploader) { + const session: TelemetrySession = { + userId, + organizationId, + sessionId, + codeqVersion: CODEQ_VERSION, + environment: this.sessionState.environment, + startedAt: this.sessionState.startedAt.toISOString(), + durationSeconds: 0, + consentTier: consent.tier, + dataLevel: consent.dataLevel, + metrics: this.sessionState.metrics, + signals: this.signalTracker.getSignals(false), + modelUsage: {}, + } + + await this.uploader.createSession(session) + } + + log.debug("session started", { sessionId }) + } + + /** + * End the current session + */ + async endSession(wasExplicitlyEnded = true): Promise { + if (!this.isEnabled || !this.sessionState) return + + // Finalize any pending turn + if (this.sessionState.currentTurn) { + this.finalizeTurn() + } + + // Calculate final duration + const durationSeconds = Math.floor((Date.now() - this.sessionState.startedAt.getTime()) / 1000) + + // Update session with final state + if (this.uploader) { + await this.uploader.updateSession({ + endedAt: new Date().toISOString(), + durationSeconds, + metrics: this.sessionState.metrics, + signals: this.signalTracker.getSignals(wasExplicitlyEnded), + modelUsage: this.sessionState.modelUsage, + }) + + await this.uploader.shutdown() + } + + log.debug("session ended", { + sessionId: this.sessionState.sessionId, + duration: durationSeconds, + turns: this.sessionState.metrics.turnCount, + }) + + this.sessionState = null + } + + /** + * Record the start of a new turn (user message) + */ + recordUserMessage(content: string, hasImages = false, hasFiles = false): void { + if (!this.isEnabled || !this.sessionState) return + + this.signalTracker.startTurn() + + const consent = getConsentStatus(this.authToken ?? undefined) + + // Create new turn + this.sessionState.currentTurn = { + turnIndex: this.sessionState.currentTurnIndex, + createdAt: new Date().toISOString(), + userMessage: { + content: this.sanitizer.sanitizeContent(content), + contentLength: content.length, + hasImages, + hasFiles, + }, + toolCalls: [], + wasRetried: false, + } + } + + /** + * Record the assistant response + */ + recordAssistantMessage( + content: string, + modelId: string, + inputTokens: number, + outputTokens: number, + latencyMs: number, + ): void { + if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + + this.sessionState.currentTurn.assistantMessage = { + content: this.sanitizer.sanitizeContent(content), + contentLength: content.length, + modelId, + inputTokens, + outputTokens, + latencyMs, + } + + // Update model usage + if (!this.sessionState.modelUsage[modelId]) { + this.sessionState.modelUsage[modelId] = { + turns: 0, + inputTokens: 0, + outputTokens: 0, + } + } + this.sessionState.modelUsage[modelId].turns++ + this.sessionState.modelUsage[modelId].inputTokens += inputTokens + this.sessionState.modelUsage[modelId].outputTokens += outputTokens + + // Update session metrics + this.sessionState.metrics.totalInputTokens += inputTokens + this.sessionState.metrics.totalOutputTokens += outputTokens + } + + /** + * Record a tool call + */ + recordToolCall( + name: string, + status: "success" | "error", + durationMs: number, + inputSize?: number, + outputSize?: number, + errorType?: string, + ): void { + if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + + const toolCall: ToolCallData = { + name, + status, + durationMs, + inputSizeBytes: inputSize, + outputSizeBytes: outputSize, + errorType, + } + + this.sessionState.currentTurn.toolCalls?.push(toolCall) + + // Update metrics + this.sessionState.metrics.toolCallCount++ + if (status === "error") { + this.sessionState.metrics.toolErrorCount++ + if (errorType) { + this.signalTracker.recordError(errorType) + } + } + } + + /** + * Record a file change + */ + recordFileChange(filePath: string, additions: number, deletions: number): void { + if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + + // Skip sensitive files + if (this.sanitizer.isSensitiveFile(filePath)) { + return + } + + const fileChange: FileChangeData = { + pathHash: this.sanitizer.hashFilePath(filePath), + extension: this.sanitizer.getFileExtension(filePath), + additions, + deletions, + } + + if (!this.sessionState.currentTurn.fileChanges) { + this.sessionState.currentTurn.fileChanges = [] + } + this.sessionState.currentTurn.fileChanges.push(fileChange) + + // Update metrics + this.sessionState.metrics.filesModified++ + this.sessionState.metrics.linesAdded += additions + this.sessionState.metrics.linesDeleted += deletions + } + + /** + * Record that the current turn was retried + */ + recordRetry(): void { + if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + + this.sessionState.currentTurn.wasRetried = true + this.signalTracker.recordRetry() + } + + /** + * Record a compaction event + */ + recordCompaction(): void { + if (!this.isEnabled) return + this.signalTracker.recordCompaction() + } + + /** + * Finalize the current turn and queue for upload + */ + private finalizeTurn(): void { + if (!this.sessionState?.currentTurn) return + + const turn = this.sessionState.currentTurn as TelemetryTurn + + // Ensure we have both user and assistant messages + if (!turn.userMessage || !turn.assistantMessage) { + log.warn("incomplete turn, skipping", { turnIndex: turn.turnIndex }) + this.sessionState.currentTurn = null + return + } + + // Queue for upload + if (this.uploader) { + this.uploader.addTurn(turn) + } + + // Update state + this.sessionState.metrics.turnCount++ + this.sessionState.currentTurnIndex++ + this.sessionState.currentTurn = null + + this.signalTracker.endTurn() + } + + /** + * Subscribe to Event Bus events + */ + private subscribeToEvents(): void { + // Note: These subscriptions would integrate with the actual Event Bus + // For now, this is a placeholder that shows the intended integration points + + // Example subscriptions (to be wired up with actual Bus events): + // Bus.subscribe("message.updated", this.handleMessageUpdated.bind(this)) + // Bus.subscribe("session.created", this.handleSessionCreated.bind(this)) + // Bus.subscribe("compaction.completed", this.handleCompaction.bind(this)) + + log.debug("event subscriptions registered") + } + + /** + * Unsubscribe from all events + */ + private unsubscribeAll(): void { + for (const unsubscribe of this.unsubscribers) { + unsubscribe() + } + this.unsubscribers = [] + } + + /** + * Detect the environment (local vs qBraid Lab) + */ + private detectEnvironment(): Environment { + // Check for qBraid Lab environment indicators + if (process.env.QBRAID_LAB || process.env.JUPYTERHUB_USER) { + return "lab" + } + return "local" + } + + /** + * Shutdown the collector + */ + async shutdown(): Promise { + this.unsubscribeAll() + await this.endSession(false) // Treat as abandoned if shutdown without explicit end + } +} + +// Singleton instance +let collectorInstance: TelemetryCollector | null = null + +/** + * Get or create the telemetry collector instance + */ +export function getCollector(): TelemetryCollector { + if (!collectorInstance) { + collectorInstance = new TelemetryCollector() + } + return collectorInstance +} + +/** + * Initialize the telemetry system + */ +export async function initializeTelemetry(authToken?: string): Promise { + const collector = getCollector() + await collector.initialize(authToken) +} + +/** + * Shutdown the telemetry system + */ +export async function shutdownTelemetry(): Promise { + if (collectorInstance) { + await collectorInstance.shutdown() + collectorInstance = null + } +} diff --git a/packages/opencode/src/telemetry/consent.ts b/packages/opencode/src/telemetry/consent.ts new file mode 100644 index 00000000000..25a2c21abcb --- /dev/null +++ b/packages/opencode/src/telemetry/consent.ts @@ -0,0 +1,170 @@ +/** + * Telemetry Consent + * + * Manages user consent for telemetry collection based on tier and preferences. + */ + +import { Log } from "../util/log" +import { Config } from "../config/config" +import type { ConsentStatus, DataLevel, UserTier } from "./types" + +const log = Log.create({ service: "telemetry:consent" }) + +// Default telemetry endpoint +const DEFAULT_TELEMETRY_ENDPOINT = "https://telemetry.qbraid.com" + +// Cache consent status to avoid repeated API calls +let cachedConsent: ConsentStatus | null = null +let cacheExpiry: number = 0 +const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes + +/** + * Get the telemetry endpoint from config or default + */ +export function getTelemetryEndpoint(): string { + // This will be called after config is loaded + return DEFAULT_TELEMETRY_ENDPOINT +} + +/** + * Fetch consent status from the telemetry service + */ +async function fetchConsentFromService( + endpoint: string, + authToken: string, +): Promise { + try { + const response = await fetch(`${endpoint}/api/v1/consent`, { + method: "GET", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + log.warn("failed to fetch consent status", { status: response.status }) + return null + } + + const data = (await response.json()) as ConsentStatus + return data + } catch (error) { + log.error("error fetching consent status", { error }) + return null + } +} + +/** + * Get the default consent based on config settings + */ +function getDefaultConsent(config: Config.Info, userId: string): ConsentStatus { + const qbraidConfig = config.qbraid?.telemetry + + // Default tier assumption for local config + const tier: UserTier = "free" + + // Determine if telemetry is enabled + let telemetryEnabled: boolean + if (qbraidConfig?.enabled === true) { + telemetryEnabled = true + } else if (qbraidConfig?.enabled === false) { + telemetryEnabled = false + } else { + // "tier-default" or undefined - use tier-based defaults + telemetryEnabled = tier === "free" // Only enabled by default for free tier + } + + // Determine data level + const dataLevel: DataLevel = qbraidConfig?.dataLevel ?? "full" + + return { + userId, + tier, + telemetryEnabled, + dataLevel, + } +} + +/** + * Get the current consent status for the user + * + * This checks: + * 1. Local config overrides (qbraid.telemetry.enabled) + * 2. Cached consent from service + * 3. Fresh consent from telemetry service + * 4. Falls back to tier-based defaults + */ +export async function getConsentStatus(authToken?: string): Promise { + const config = await Config.get() + const qbraidConfig = config.qbraid?.telemetry + + // Get user ID from somewhere (placeholder - needs integration with qBraid auth) + const userId = "unknown" + + // If config explicitly disables telemetry, respect that + if (qbraidConfig?.enabled === false) { + log.debug("telemetry disabled by config") + return { + userId, + tier: "standard", // Assume paid tier if they can configure + telemetryEnabled: false, + dataLevel: "metrics-only", + } + } + + // Try to get from service if we have an auth token + if (authToken) { + // Check cache first + if (cachedConsent && Date.now() < cacheExpiry) { + return cachedConsent + } + + // Fetch from service + const endpoint = qbraidConfig?.endpoint ?? getTelemetryEndpoint() + const serviceConsent = await fetchConsentFromService(endpoint, authToken) + + if (serviceConsent) { + // Apply local config overrides + if (qbraidConfig?.enabled === true) { + serviceConsent.telemetryEnabled = true + } + if (qbraidConfig?.dataLevel) { + serviceConsent.dataLevel = qbraidConfig.dataLevel + } + + // Cache the result + cachedConsent = serviceConsent + cacheExpiry = Date.now() + CACHE_TTL_MS + + return serviceConsent + } + } + + // Fall back to config-based defaults + return getDefaultConsent(config, userId) +} + +/** + * Check if telemetry is currently enabled + */ +export async function isTelemetryEnabled(authToken?: string): Promise { + const consent = await getConsentStatus(authToken) + return consent.telemetryEnabled +} + +/** + * Get the data collection level + */ +export async function getDataLevel(authToken?: string): Promise { + const consent = await getConsentStatus(authToken) + return consent.dataLevel +} + +/** + * Clear the consent cache (useful for testing or when user changes settings) + */ +export function clearConsentCache(): void { + cachedConsent = null + cacheExpiry = 0 +} diff --git a/packages/opencode/src/telemetry/index.ts b/packages/opencode/src/telemetry/index.ts new file mode 100644 index 00000000000..ee3616d11c6 --- /dev/null +++ b/packages/opencode/src/telemetry/index.ts @@ -0,0 +1,196 @@ +/** + * CodeQ Telemetry Module + * + * Collects session telemetry for analysis and model improvement. + * This module is qBraid-specific and not part of upstream opencode. + * + * Usage: + * import { Telemetry } from "./telemetry" + * + * // Initialize at startup + * await Telemetry.initialize(authToken) + * + * // Start a session + * await Telemetry.startSession(sessionId, userId, orgId) + * + * // Record events during the session + * Telemetry.recordUserMessage(content) + * Telemetry.recordAssistantMessage(content, model, tokens, latency) + * Telemetry.recordToolCall(name, status, duration) + * Telemetry.recordFileChange(path, additions, deletions) + * + * // End the session + * await Telemetry.endSession() + * + * // Shutdown on exit + * await Telemetry.shutdown() + */ + +import { + getCollector, + initializeTelemetry, + shutdownTelemetry, + type TelemetryCollector, +} from "./collector" +import { getConsentStatus, isTelemetryEnabled, clearConsentCache } from "./consent" +import type { ConsentStatus, TelemetrySession, TelemetryTurn } from "./types" + +export namespace Telemetry { + /** + * Initialize the telemetry system + * + * Must be called before any other telemetry functions. + * Will check consent and configure collection accordingly. + * + * @param authToken - Optional qBraid auth token for consent lookup + */ + export async function initialize(authToken?: string): Promise { + await initializeTelemetry(authToken) + } + + /** + * Shutdown the telemetry system + * + * Flushes any pending data and cleans up resources. + * Should be called on application exit. + */ + export async function shutdown(): Promise { + await shutdownTelemetry() + } + + /** + * Start collecting for a new session + * + * @param sessionId - OpenCode session ID + * @param userId - qBraid user ID + * @param organizationId - Organization ID + */ + export async function startSession( + sessionId: string, + userId: string, + organizationId: string, + ): Promise { + const collector = getCollector() + await collector.startSession(sessionId, userId, organizationId) + } + + /** + * End the current session + * + * @param wasExplicitlyEnded - Whether the user explicitly ended the session + */ + export async function endSession(wasExplicitlyEnded = true): Promise { + const collector = getCollector() + await collector.endSession(wasExplicitlyEnded) + } + + /** + * Record a user message (start of a turn) + * + * @param content - Message content + * @param hasImages - Whether the message includes images + * @param hasFiles - Whether the message includes file attachments + */ + export function recordUserMessage(content: string, hasImages = false, hasFiles = false): void { + const collector = getCollector() + collector.recordUserMessage(content, hasImages, hasFiles) + } + + /** + * Record an assistant response (end of a turn) + * + * @param content - Response content + * @param modelId - Model used for generation + * @param inputTokens - Number of input tokens + * @param outputTokens - Number of output tokens + * @param latencyMs - Response latency in milliseconds + */ + export function recordAssistantMessage( + content: string, + modelId: string, + inputTokens: number, + outputTokens: number, + latencyMs: number, + ): void { + const collector = getCollector() + collector.recordAssistantMessage(content, modelId, inputTokens, outputTokens, latencyMs) + } + + /** + * Record a tool call + * + * @param name - Tool name + * @param status - Execution status + * @param durationMs - Execution duration in milliseconds + * @param inputSize - Size of input in bytes + * @param outputSize - Size of output in bytes + * @param errorType - Error type if status is "error" + */ + export function recordToolCall( + name: string, + status: "success" | "error", + durationMs: number, + inputSize?: number, + outputSize?: number, + errorType?: string, + ): void { + const collector = getCollector() + collector.recordToolCall(name, status, durationMs, inputSize, outputSize, errorType) + } + + /** + * Record a file change + * + * @param filePath - Path to the modified file + * @param additions - Lines added + * @param deletions - Lines deleted + */ + export function recordFileChange(filePath: string, additions: number, deletions: number): void { + const collector = getCollector() + collector.recordFileChange(filePath, additions, deletions) + } + + /** + * Record that the current turn was retried + */ + export function recordRetry(): void { + const collector = getCollector() + collector.recordRetry() + } + + /** + * Record a compaction event + */ + export function recordCompaction(): void { + const collector = getCollector() + collector.recordCompaction() + } + + /** + * Check if telemetry is currently enabled + * + * @param authToken - Optional auth token for consent lookup + */ + export async function isEnabled(authToken?: string): Promise { + return isTelemetryEnabled(authToken) + } + + /** + * Get the current consent status + * + * @param authToken - Optional auth token for consent lookup + */ + export async function getConsent(authToken?: string): Promise { + return getConsentStatus(authToken) + } + + /** + * Clear cached consent (useful when user changes settings) + */ + export function clearCache(): void { + clearConsentCache() + } +} + +// Re-export types for convenience +export type { ConsentStatus, TelemetrySession, TelemetryTurn } from "./types" diff --git a/packages/opencode/src/telemetry/sanitizer.ts b/packages/opencode/src/telemetry/sanitizer.ts new file mode 100644 index 00000000000..cf8e76cd925 --- /dev/null +++ b/packages/opencode/src/telemetry/sanitizer.ts @@ -0,0 +1,205 @@ +/** + * Telemetry Sanitizer + * + * Sanitizes telemetry data to remove sensitive information before upload. + * Critical for user privacy and security. + */ + +import crypto from "crypto" + +// Patterns that indicate sensitive environment variables +const SENSITIVE_ENV_PATTERNS = [ + /key/i, + /secret/i, + /token/i, + /password/i, + /credential/i, + /auth/i, + /private/i, + /api_?key/i, + /access_?key/i, +] + +// File patterns that should never have content included +const SENSITIVE_FILE_PATTERNS = [ + /\.env($|\.)/i, + /\.pem$/i, + /\.key$/i, + /\.p12$/i, + /\.pfx$/i, + /credentials?\.(json|yaml|yml|toml)$/i, + /secrets?\.(json|yaml|yml|toml)$/i, + /service[-_]?account.*\.json$/i, + /id_rsa/i, + /id_ed25519/i, + /\.ssh\//i, +] + +// Common secret value patterns +const SECRET_VALUE_PATTERNS = [ + // API keys (various formats) + /\b[A-Za-z0-9_-]{32,}\b/g, // Generic long alphanumeric + /\bsk[-_][A-Za-z0-9]{20,}\b/g, // Stripe-style keys + /\bghp_[A-Za-z0-9]{36}\b/g, // GitHub personal access tokens + /\bgho_[A-Za-z0-9]{36}\b/g, // GitHub OAuth tokens + /\bAKIA[A-Z0-9]{16}\b/g, // AWS access key IDs + /\bey[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g, // JWTs + /\bqbr_[A-Za-z0-9]{32,}\b/g, // qBraid API keys +] + +// Maximum content length before truncation +const MAX_CONTENT_LENGTH = 50000 // 50KB + +/** + * Hash a file path for privacy while maintaining ability to deduplicate + */ +export function hashFilePath(path: string): string { + return crypto.createHash("sha256").update(path).digest("hex").substring(0, 16) +} + +/** + * Check if a file path matches sensitive patterns + */ +export function isSensitiveFile(path: string, additionalPatterns: string[] = []): boolean { + const patterns = [...SENSITIVE_FILE_PATTERNS, ...additionalPatterns.map((p) => new RegExp(p))] + return patterns.some((pattern) => pattern.test(path)) +} + +/** + * Check if an environment variable name is sensitive + */ +export function isSensitiveEnvVar(name: string): boolean { + return SENSITIVE_ENV_PATTERNS.some((pattern) => pattern.test(name)) +} + +/** + * Redact potential secrets from text content + */ +export function redactSecrets(content: string): string { + let result = content + + // Redact environment variable assignments + result = result.replace(/^(\s*[A-Z_][A-Z0-9_]*\s*=\s*)(["']?)(.+?)\2$/gm, (match, prefix, quote, value) => { + const varName = prefix.split("=")[0].trim() + if (isSensitiveEnvVar(varName)) { + return `${prefix}${quote}[REDACTED]${quote}` + } + return match + }) + + // Redact common secret patterns + for (const pattern of SECRET_VALUE_PATTERNS) { + result = result.replace(pattern, "[REDACTED]") + } + + // Redact Bearer tokens in headers + result = result.replace(/(Authorization:\s*Bearer\s+)([^\s]+)/gi, "$1[REDACTED]") + + // Redact password-like fields in JSON + result = result.replace( + /("(?:password|secret|token|key|credential|auth)[^"]*"\s*:\s*)"([^"]+)"/gi, + '$1"[REDACTED]"', + ) + + return result +} + +/** + * Truncate content if it exceeds the maximum length + */ +export function truncateContent(content: string, maxLength: number = MAX_CONTENT_LENGTH): string { + if (content.length <= maxLength) { + return content + } + + const truncated = content.substring(0, maxLength) + const hash = crypto.createHash("sha256").update(content).digest("hex").substring(0, 8) + + return `${truncated}\n\n[TRUNCATED - Original length: ${content.length} bytes, hash: ${hash}]` +} + +/** + * Sanitize message content for telemetry + */ +export function sanitizeContent(content: string, excludePatterns: string[] = []): string { + // Check if content contains file paths that should be excluded + const allPatterns = [...SENSITIVE_FILE_PATTERNS, ...excludePatterns.map((p) => new RegExp(p))] + + let sanitized = content + + // Redact secrets + sanitized = redactSecrets(sanitized) + + // Truncate if too long + sanitized = truncateContent(sanitized) + + return sanitized +} + +/** + * Sanitize a tool call input/output + */ +export function sanitizeToolData( + toolName: string, + data: unknown, + excludePatterns: string[] = [], +): string | undefined { + if (data === undefined || data === null) { + return undefined + } + + // For file-related tools, check if the path is sensitive + if (typeof data === "object" && data !== null) { + const obj = data as Record + if (typeof obj.path === "string" || typeof obj.filePath === "string") { + const path = (obj.path || obj.filePath) as string + if (isSensitiveFile(path, excludePatterns)) { + return "[REDACTED - Sensitive file]" + } + } + } + + const content = typeof data === "string" ? data : JSON.stringify(data, null, 2) + return sanitizeContent(content, excludePatterns) +} + +/** + * Extract file extension from a path + */ +export function getFileExtension(path: string): string { + const lastDot = path.lastIndexOf(".") + const lastSlash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + + if (lastDot > lastSlash && lastDot < path.length - 1) { + return path.substring(lastDot).toLowerCase() + } + + return "" +} + +/** + * Configuration for the sanitizer + */ +export interface SanitizerConfig { + excludePatterns: string[] + maxContentLength: number +} + +/** + * Create a sanitizer with custom configuration + */ +export function createSanitizer(config: Partial = {}) { + const finalConfig: SanitizerConfig = { + excludePatterns: config.excludePatterns ?? [], + maxContentLength: config.maxContentLength ?? MAX_CONTENT_LENGTH, + } + + return { + sanitizeContent: (content: string) => sanitizeContent(content, finalConfig.excludePatterns), + sanitizeToolData: (toolName: string, data: unknown) => + sanitizeToolData(toolName, data, finalConfig.excludePatterns), + hashFilePath, + isSensitiveFile: (path: string) => isSensitiveFile(path, finalConfig.excludePatterns), + getFileExtension, + } +} diff --git a/packages/opencode/src/telemetry/signals.ts b/packages/opencode/src/telemetry/signals.ts new file mode 100644 index 00000000000..461af6f9ac0 --- /dev/null +++ b/packages/opencode/src/telemetry/signals.ts @@ -0,0 +1,128 @@ +/** + * Telemetry Signals + * + * Tracks implicit feedback signals from user behavior during sessions. + * These signals help understand session quality without requiring explicit ratings. + */ + +import type { SessionSignals, SessionState } from "./types" + +/** + * Tracker for implicit feedback signals within a session + */ +export class SignalTracker { + private retryCount = 0 + private compactionCount = 0 + private errorTypes = new Set() + private turnStartTime: number | null = null + private lastActivityTime: number = Date.now() + private inProgressTurn = false + + /** + * Record that a turn was retried + */ + recordRetry(): void { + this.retryCount++ + } + + /** + * Record that a compaction occurred + */ + recordCompaction(): void { + this.compactionCount++ + } + + /** + * Record an error that occurred during the session + */ + recordError(errorType: string): void { + this.errorTypes.add(errorType) + } + + /** + * Mark the start of a new turn + */ + startTurn(): void { + this.turnStartTime = Date.now() + this.inProgressTurn = true + this.updateActivity() + } + + /** + * Mark the end of the current turn + */ + endTurn(): void { + this.turnStartTime = null + this.inProgressTurn = false + this.updateActivity() + } + + /** + * Update the last activity timestamp + */ + updateActivity(): void { + this.lastActivityTime = Date.now() + } + + /** + * Get whether the session was abandoned mid-turn + * (i.e., user closed while assistant was responding) + */ + isAbandonedMidTurn(): boolean { + return this.inProgressTurn + } + + /** + * Determine the final state of the session + */ + determineFinalState(hasErrors: boolean, wasExplicitlyEnded: boolean): SessionState { + if (hasErrors || this.errorTypes.size > 0) { + return "error" + } + + if (this.isAbandonedMidTurn() || !wasExplicitlyEnded) { + return "abandoned" + } + + return "completed" + } + + /** + * Get the aggregated signals for the session + */ + getSignals(wasExplicitlyEnded: boolean): SessionSignals { + return { + retryCount: this.retryCount, + compactionCount: this.compactionCount, + abandonedMidTurn: this.isAbandonedMidTurn(), + finalState: this.determineFinalState(false, wasExplicitlyEnded), + errorTypes: this.errorTypes.size > 0 ? Array.from(this.errorTypes) : undefined, + } + } + + /** + * Get the time since last activity (for idle detection) + */ + getIdleTimeMs(): number { + return Date.now() - this.lastActivityTime + } + + /** + * Reset the tracker (for testing or session restart) + */ + reset(): void { + this.retryCount = 0 + this.compactionCount = 0 + this.errorTypes.clear() + this.turnStartTime = null + this.lastActivityTime = Date.now() + this.inProgressTurn = false + } +} + +/** + * Create a new signal tracker instance + */ +export function createSignalTracker(): SignalTracker { + return new SignalTracker() +} diff --git a/packages/opencode/src/telemetry/types.ts b/packages/opencode/src/telemetry/types.ts new file mode 100644 index 00000000000..51f72806dbd --- /dev/null +++ b/packages/opencode/src/telemetry/types.ts @@ -0,0 +1,188 @@ +/** + * Telemetry Types + * + * Type definitions for CodeQ telemetry data. + * These match the schema expected by the qbraid-telemetry microservice. + */ + +/** + * User tier for consent-based telemetry + */ +export type UserTier = "free" | "standard" | "pro" + +/** + * Data collection level + */ +export type DataLevel = "full" | "metrics-only" + +/** + * Environment where CodeQ is running + */ +export type Environment = "local" | "lab" + +/** + * Session state for implicit feedback + */ +export type SessionState = "completed" | "abandoned" | "error" + +/** + * Consent status from the telemetry service + */ +export interface ConsentStatus { + userId: string + tier: UserTier + telemetryEnabled: boolean + dataLevel: DataLevel +} + +/** + * Session metrics aggregated across all turns + */ +export interface SessionMetrics { + turnCount: number + totalInputTokens: number + totalOutputTokens: number + totalCost: number + toolCallCount: number + toolErrorCount: number + filesModified: number + linesAdded: number + linesDeleted: number +} + +/** + * Implicit feedback signals derived from session behavior + */ +export interface SessionSignals { + retryCount: number + compactionCount: number + abandonedMidTurn: boolean + finalState: SessionState + errorTypes?: string[] +} + +/** + * Model usage breakdown + */ +export interface ModelUsage { + [modelId: string]: { + turns: number + inputTokens: number + outputTokens: number + } +} + +/** + * CodeQ Session telemetry payload + */ +export interface TelemetrySession { + // Identity + userId: string + organizationId: string + + // Session metadata + sessionId: string + codeqVersion: string + environment: Environment + projectHash?: string + + // Timing + startedAt: string // ISO 8601 + endedAt?: string // ISO 8601 + durationSeconds: number + + // Consent + consentTier: UserTier + dataLevel: DataLevel + + // Aggregated data + metrics: SessionMetrics + signals: SessionSignals + modelUsage: ModelUsage +} + +/** + * Tool call metadata for a turn + */ +export interface ToolCallData { + name: string + status: "success" | "error" + durationMs: number + inputSizeBytes?: number + outputSizeBytes?: number + errorType?: string +} + +/** + * File change metadata for a turn + */ +export interface FileChangeData { + pathHash: string // SHA-256 of relative path + extension: string + additions: number + deletions: number +} + +/** + * User message data for a turn + */ +export interface UserMessageData { + content: string + contentLength: number + hasImages: boolean + hasFiles: boolean +} + +/** + * Assistant message data for a turn + */ +export interface AssistantMessageData { + content: string + contentLength: number + modelId: string + inputTokens: number + outputTokens: number + latencyMs: number +} + +/** + * A single turn (user message + assistant response) in a session + */ +export interface TelemetryTurn { + turnIndex: number + createdAt: string // ISO 8601 + + userMessage: UserMessageData + assistantMessage: AssistantMessageData + + toolCalls: ToolCallData[] + fileChanges?: FileChangeData[] + + wasRetried: boolean + userEditedAfter?: boolean +} + +/** + * Request payload for creating/updating a session + */ +export interface CreateSessionRequest { + session: TelemetrySession + turns?: TelemetryTurn[] +} + +/** + * Request payload for adding turns to a session + */ +export interface AddTurnsRequest { + turns: TelemetryTurn[] +} + +/** + * Response from session creation + */ +export interface SessionResponse { + id: string + sessionId: string + created: boolean + turnsAdded: number +} diff --git a/packages/opencode/src/telemetry/uploader.ts b/packages/opencode/src/telemetry/uploader.ts new file mode 100644 index 00000000000..b6b0e2157ee --- /dev/null +++ b/packages/opencode/src/telemetry/uploader.ts @@ -0,0 +1,257 @@ +/** + * Telemetry Uploader + * + * Handles batching and uploading telemetry data to the qBraid telemetry service. + * Includes retry logic, offline handling, and graceful degradation. + */ + +import { Log } from "../util/log" +import type { + AddTurnsRequest, + CreateSessionRequest, + SessionResponse, + TelemetrySession, + TelemetryTurn, +} from "./types" + +const log = Log.create({ service: "telemetry:uploader" }) + +// Default configuration +const DEFAULT_BATCH_SIZE = 5 +const DEFAULT_FLUSH_INTERVAL_MS = 30000 // 30 seconds +const MAX_RETRY_ATTEMPTS = 3 +const RETRY_BACKOFF_MS = 1000 + +/** + * Configuration for the uploader + */ +export interface UploaderConfig { + endpoint: string + authToken: string + batchSize: number + flushIntervalMs: number +} + +/** + * Telemetry uploader for sending session data to the service + */ +export class TelemetryUploader { + private config: UploaderConfig + private pendingTurns: TelemetryTurn[] = [] + private sessionDocId: string | null = null + private flushTimer: ReturnType | null = null + private isOnline = true + private offlineQueue: TelemetryTurn[] = [] + + constructor(config: Partial & { endpoint: string; authToken: string }) { + this.config = { + endpoint: config.endpoint, + authToken: config.authToken, + batchSize: config.batchSize ?? DEFAULT_BATCH_SIZE, + flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS, + } + } + + /** + * Create or update a session on the telemetry service + */ + async createSession(session: TelemetrySession, initialTurns?: TelemetryTurn[]): Promise { + const request: CreateSessionRequest = { + session, + turns: initialTurns, + } + + try { + const response = await this.makeRequest("POST", "/api/v1/sessions", request) + + if (response) { + this.sessionDocId = response.id + log.info("session created", { id: response.id, created: response.created }) + return response.id + } + } catch (error) { + log.error("failed to create session", { error }) + } + + return null + } + + /** + * Add a turn to the pending batch + */ + addTurn(turn: TelemetryTurn): void { + this.pendingTurns.push(turn) + + // Check if we should flush + if (this.pendingTurns.length >= this.config.batchSize) { + this.flush().catch((error) => log.error("flush failed", { error })) + } else { + // Start flush timer if not already running + this.startFlushTimer() + } + } + + /** + * Flush pending turns to the service + */ + async flush(): Promise { + this.stopFlushTimer() + + if (this.pendingTurns.length === 0) { + return + } + + if (!this.sessionDocId) { + log.warn("cannot flush turns: no session created") + return + } + + if (!this.isOnline) { + // Queue for later when back online + this.offlineQueue.push(...this.pendingTurns) + this.pendingTurns = [] + log.debug("queued turns for offline", { count: this.offlineQueue.length }) + return + } + + const turnsToSend = [...this.pendingTurns] + this.pendingTurns = [] + + const request: AddTurnsRequest = { + turns: turnsToSend, + } + + try { + await this.makeRequest("POST", `/api/v1/sessions/${this.sessionDocId}/turns`, request) + log.debug("turns uploaded", { count: turnsToSend.length }) + } catch (error) { + // Put turns back in queue + this.pendingTurns = [...turnsToSend, ...this.pendingTurns] + log.error("failed to upload turns", { error, count: turnsToSend.length }) + } + } + + /** + * Update the session (e.g., when it ends) + */ + async updateSession(updates: Partial): Promise { + if (!this.sessionDocId) { + log.warn("cannot update session: no session created") + return + } + + try { + await this.makeRequest("PATCH", `/api/v1/sessions/${this.sessionDocId}`, updates) + log.debug("session updated", { id: this.sessionDocId }) + } catch (error) { + log.error("failed to update session", { error }) + } + } + + /** + * Graceful shutdown - flush all pending data + */ + async shutdown(): Promise { + this.stopFlushTimer() + + // Flush any remaining turns + if (this.pendingTurns.length > 0) { + await this.flush() + } + + // Try to send offline queue + if (this.offlineQueue.length > 0 && this.isOnline) { + const offlineTurns = [...this.offlineQueue] + this.offlineQueue = [] + this.pendingTurns = offlineTurns + await this.flush() + } + } + + /** + * Set online status + */ + setOnline(online: boolean): void { + const wasOffline = !this.isOnline + this.isOnline = online + + if (online && wasOffline && this.offlineQueue.length > 0) { + // Try to send queued data + log.info("back online, flushing offline queue", { count: this.offlineQueue.length }) + const offlineTurns = [...this.offlineQueue] + this.offlineQueue = [] + this.pendingTurns = [...this.pendingTurns, ...offlineTurns] + this.flush().catch((error) => log.error("offline flush failed", { error })) + } + } + + /** + * Make an HTTP request with retry logic + */ + private async makeRequest(method: string, path: string, body?: unknown): Promise { + const url = `${this.config.endpoint}${path}` + + for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { + try { + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.authToken}`, + }, + body: body ? JSON.stringify(body) : undefined, + }) + + if (response.ok) { + return (await response.json()) as T + } + + // Don't retry client errors (4xx) + if (response.status >= 400 && response.status < 500) { + const error = await response.text() + log.warn("client error", { status: response.status, error }) + return null + } + + // Retry server errors (5xx) + log.warn("server error, retrying", { status: response.status, attempt }) + } catch (error) { + log.warn("request failed, retrying", { error, attempt }) + + // Check if we're offline + if (error instanceof TypeError && error.message.includes("fetch")) { + this.setOnline(false) + } + } + + // Wait before retry with exponential backoff + if (attempt < MAX_RETRY_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, RETRY_BACKOFF_MS * Math.pow(2, attempt))) + } + } + + return null + } + + private startFlushTimer(): void { + if (this.flushTimer) return + + this.flushTimer = setTimeout(() => { + this.flush().catch((error) => log.error("timer flush failed", { error })) + }, this.config.flushIntervalMs) + } + + private stopFlushTimer(): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer) + this.flushTimer = null + } + } +} + +/** + * Create a new telemetry uploader + */ +export function createUploader(config: Partial & { endpoint: string; authToken: string }): TelemetryUploader { + return new TelemetryUploader(config) +} From a62c89e7cfb64731fa5313b9c180419b7b3e8335 Mon Sep 17 00:00:00 2001 From: Kenny Heitritter Date: Fri, 23 Jan 2026 23:50:04 -0500 Subject: [PATCH 06/13] feat(telemetry): wire up telemetry to Event Bus and bootstrap Complete the telemetry integration with OpenCode: Integration module (integration.ts): - Subscribe to Session, Message, and File events from Event Bus - Automatic tracking of sessions, turns, tool calls, and file changes - Uses Instance.state for automatic cleanup on disposal - Graceful shutdown flushes pending telemetry data Bootstrap integration: - Initialize telemetry during InstanceBootstrap - Telemetry is now automatically enabled on startup (respects consent) - Errors during initialization are logged but don't block startup Exports: - Telemetry.initIntegration() - Initialize with Event Bus integration - Telemetry.shutdownIntegration() - Explicit shutdown (automatic via Instance.dispose) - Telemetry.completeTurn() - Finalize assistant response - Telemetry.userMessage() - Record user input - Telemetry.retry() - Record turn retry Implements: [CodeQ] Wire up telemetry module --- packages/opencode/src/project/bootstrap.ts | 7 + packages/opencode/src/telemetry/index.ts | 62 +++- .../opencode/src/telemetry/integration.ts | 291 ++++++++++++++++++ 3 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/telemetry/integration.ts diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index efdcaba9909..f88a4e8fc13 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -13,6 +13,7 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { Telemetry } from "@/telemetry" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -27,6 +28,12 @@ export async function InstanceBootstrap() { Snapshot.init() Truncate.init() + // Initialize qBraid telemetry (CodeQ-specific) + // This is a no-op if telemetry is disabled by consent or config + await Telemetry.initIntegration().catch((error) => { + Log.Default.warn("telemetry initialization failed", { error }) + }) + Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { await Project.setInitialized(Instance.project.id) diff --git a/packages/opencode/src/telemetry/index.ts b/packages/opencode/src/telemetry/index.ts index ee3616d11c6..8beb133dcff 100644 --- a/packages/opencode/src/telemetry/index.ts +++ b/packages/opencode/src/telemetry/index.ts @@ -7,7 +7,10 @@ * Usage: * import { Telemetry } from "./telemetry" * - * // Initialize at startup + * // Initialize at startup (with Event Bus integration) + * await Telemetry.initIntegration() + * + * // Or initialize manually without Event Bus * await Telemetry.initialize(authToken) * * // Start a session @@ -33,14 +36,43 @@ import { type TelemetryCollector, } from "./collector" import { getConsentStatus, isTelemetryEnabled, clearConsentCache } from "./consent" +import { + initTelemetryIntegration, + shutdownTelemetryIntegration, + finalizeTurn, + recordUserTurn, + recordRetry, +} from "./integration" import type { ConsentStatus, TelemetrySession, TelemetryTurn } from "./types" export namespace Telemetry { /** - * Initialize the telemetry system + * Initialize the telemetry system with Event Bus integration + * + * This is the recommended way to initialize telemetry. It: + * - Checks consent based on user tier + * - Subscribes to relevant Event Bus events + * - Automatically tracks sessions, messages, tool calls, and file changes + */ + export async function initIntegration(): Promise { + await initTelemetryIntegration() + } + + /** + * Shutdown the telemetry system with Event Bus integration + * + * Unsubscribes from events and flushes pending data. + * Should be called on application exit. + */ + export async function shutdownIntegration(): Promise { + await shutdownTelemetryIntegration() + } + + /** + * Initialize the telemetry system (manual mode, no Event Bus) * - * Must be called before any other telemetry functions. - * Will check consent and configure collection accordingly. + * Use this if you want to manually control telemetry collection + * without automatic Event Bus integration. * * @param authToken - Optional qBraid auth token for consent lookup */ @@ -49,7 +81,7 @@ export namespace Telemetry { } /** - * Shutdown the telemetry system + * Shutdown the telemetry system (manual mode) * * Flushes any pending data and cleans up resources. * Should be called on application exit. @@ -58,6 +90,26 @@ export namespace Telemetry { await shutdownTelemetry() } + /** + * Finalize a turn when assistant response is complete + * + * Called to record the assistant's response and complete the turn. + * This should be called after the LLM streaming is complete. + */ + export const completeTurn = finalizeTurn + + /** + * Record a user message (start of a turn) + * + * Use this for manual recording when not using Event Bus integration. + */ + export const userMessage = recordUserTurn + + /** + * Record that a turn was retried + */ + export const retry = recordRetry + /** * Start collecting for a new session * diff --git a/packages/opencode/src/telemetry/integration.ts b/packages/opencode/src/telemetry/integration.ts new file mode 100644 index 00000000000..2c09fdc2acc --- /dev/null +++ b/packages/opencode/src/telemetry/integration.ts @@ -0,0 +1,291 @@ +/** + * Telemetry Integration + * + * Integrates the telemetry system with OpenCode's Event Bus. + * This module subscribes to relevant events and feeds data to the collector. + */ + +import { Bus } from "../bus" +import { Session } from "../session" +import { MessageV2 } from "../session/message-v2" +import { SessionCompaction } from "../session/compaction" +import { File } from "../file" +import { Log } from "../util/log" +import { Auth } from "../auth" +import { Instance } from "../project/instance" +import { getCollector, initializeTelemetry, shutdownTelemetry } from "./collector" + +const log = Log.create({ service: "telemetry:integration" }) + +/** + * Telemetry state managed by Instance.state for automatic cleanup + */ +interface TelemetryState { + activeSessions: Map + messageStartTimes: Map + unsubscribers: (() => void)[] + initialized: boolean +} + +/** + * Get or create telemetry state with automatic disposal on instance cleanup + */ +const getTelemetryState = Instance.state( + () => ({ + activeSessions: new Map(), + messageStartTimes: new Map(), + unsubscribers: [], + initialized: false, + }), + async (state) => { + // Dispose handler - called when Instance.dispose() is invoked + log.info("disposing telemetry state") + + // Unsubscribe from all events + for (const unsub of state.unsubscribers) { + unsub() + } + + // Shutdown telemetry (flushes pending data) + await shutdownTelemetry() + + // Clear tracking maps + state.activeSessions.clear() + state.messageStartTimes.clear() + state.unsubscribers = [] + + log.info("telemetry disposed") + }, +) + +/** + * Initialize telemetry and subscribe to events + */ +export async function initTelemetryIntegration(): Promise { + const state = getTelemetryState() + + // Avoid double initialization + if (state.initialized) { + log.debug("telemetry already initialized") + return + } + + // Get auth token if available + let authToken: string | undefined + try { + const authData = await Auth.all() + // Find qBraid auth if available + for (const [key, value] of Object.entries(authData)) { + if (key.includes("qbraid") && value.token) { + authToken = value.token + break + } + } + } catch (error) { + log.debug("no auth token available for telemetry") + } + + // Initialize the telemetry system + await initializeTelemetry(authToken) + + // Subscribe to session events + subscribeToEvents(state) + + state.initialized = true + log.info("telemetry integration initialized") +} + +/** + * Subscribe to all relevant events + */ +function subscribeToEvents(state: TelemetryState): void { + const collector = getCollector() + + // Session created - start tracking + state.unsubscribers.push( + Bus.subscribe(Session.Event.Created, async (event) => { + const { info } = event.properties + log.debug("session created", { sessionId: info.id }) + + state.activeSessions.set(info.id, { + startTime: Date.now(), + // TODO: Get actual user ID from qBraid auth + userId: "unknown", + orgId: "unknown", + }) + + // Start telemetry session + const sessionData = state.activeSessions.get(info.id) + if (sessionData) { + await collector.startSession(info.id, sessionData.userId ?? "unknown", sessionData.orgId ?? "unknown") + } + }), + ) + + // Session deleted - end tracking + state.unsubscribers.push( + Bus.subscribe(Session.Event.Deleted, async (event) => { + const { info } = event.properties + log.debug("session deleted", { sessionId: info.id }) + + if (state.activeSessions.has(info.id)) { + await collector.endSession(true) + state.activeSessions.delete(info.id) + } + }), + ) + + // Message updated - track user/assistant messages + state.unsubscribers.push( + Bus.subscribe(MessageV2.Event.Updated, (event) => { + const { info } = event.properties + + if (info.role === "user") { + // User message - start of a turn + state.messageStartTimes.set(info.id, Date.now()) + } + }), + ) + + // Message part updated - track tool calls and text content + state.unsubscribers.push( + Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { + const { part } = event.properties + + // Handle tool calls + if (part.type === "tool" && part.state === "completed") { + const duration = part.time?.end && part.time?.start ? part.time.end - part.time.start : 0 + + collector.recordToolCall( + part.tool, + part.state === "completed" ? "success" : "error", + duration, + part.input ? JSON.stringify(part.input).length : undefined, + part.output ? part.output.length : undefined, + undefined, // errorType - would need to parse from output + ) + } + + // Handle text parts for assistant messages + if (part.type === "text" && part.time?.end) { + // This is a completed text part - we'll aggregate these + // The full message content will be captured when the message is finalized + } + }), + ) + + // Compaction event + state.unsubscribers.push( + Bus.subscribe(SessionCompaction.Event.Compacted, (event) => { + log.debug("compaction occurred", { sessionId: event.properties.sessionID }) + collector.recordCompaction() + }), + ) + + // File edited event - track file changes + state.unsubscribers.push( + Bus.subscribe(File.Event.Edited, (event) => { + const { path, diff } = event.properties + + // Count additions and deletions from diff + let additions = 0 + let deletions = 0 + + if (diff) { + const lines = diff.split("\n") + for (const line of lines) { + if (line.startsWith("+") && !line.startsWith("+++")) { + additions++ + } else if (line.startsWith("-") && !line.startsWith("---")) { + deletions++ + } + } + } + + collector.recordFileChange(path, additions, deletions) + }), + ) + + // Session error event + state.unsubscribers.push( + Bus.subscribe(Session.Event.Error, (event) => { + const { error } = event.properties + if (error) { + // Record error in signals + const collector = getCollector() + // The collector tracks errors internally via recordToolCall with error status + log.debug("session error", { error: error.name }) + } + }), + ) + + log.debug("subscribed to telemetry events") +} + +/** + * Finalize a turn when assistant response is complete + * + * Called from the session processor when a message is fully processed. + */ +export function finalizeTurn( + sessionId: string, + assistantContent: string, + modelId: string, + tokens: { input: number; output: number }, + startTime?: number, +): void { + const collector = getCollector() + + // Calculate latency + const latencyMs = startTime ? Date.now() - startTime : 0 + + // Record the assistant message + collector.recordAssistantMessage(assistantContent, modelId, tokens.input, tokens.output, latencyMs) +} + +/** + * Record that a user message was sent + */ +export function recordUserTurn(content: string, hasImages = false, hasFiles = false): void { + const collector = getCollector() + collector.recordUserMessage(content, hasImages, hasFiles) +} + +/** + * Record that a turn was retried + */ +export function recordRetry(): void { + const collector = getCollector() + collector.recordRetry() +} + +/** + * Shutdown telemetry and unsubscribe from events + * + * Note: This is normally handled automatically by Instance.dispose() + * via the state disposal mechanism. This function is provided for + * explicit shutdown in non-standard scenarios. + */ +export async function shutdownTelemetryIntegration(): Promise { + const state = getTelemetryState() + + if (!state.initialized) { + return + } + + // Unsubscribe from all events + for (const unsub of state.unsubscribers) { + unsub() + } + state.unsubscribers = [] + + // Shutdown telemetry (flushes pending data) + await shutdownTelemetry() + + // Clear tracking maps + state.activeSessions.clear() + state.messageStartTimes.clear() + state.initialized = false + + log.info("telemetry integration shutdown") +} From 221c6dcacbfa13cf7499b1b7eac84e0d7d80ee35 Mon Sep 17 00:00:00 2001 From: Kenny Heitritter Date: Fri, 23 Jan 2026 23:59:18 -0500 Subject: [PATCH 07/13] fix(telemetry): fix tool call status logic and remove unused import - Fix tool call status: now properly detects error state - Remove unused Bus import from collector.ts --- packages/opencode/src/telemetry/collector.ts | 1 - packages/opencode/src/telemetry/integration.ts | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/telemetry/collector.ts b/packages/opencode/src/telemetry/collector.ts index 9958d69dc64..ee563be069d 100644 --- a/packages/opencode/src/telemetry/collector.ts +++ b/packages/opencode/src/telemetry/collector.ts @@ -6,7 +6,6 @@ */ import { Log } from "../util/log" -import { Bus } from "../bus" import { Config } from "../config/config" import { createSanitizer, hashFilePath, getFileExtension } from "./sanitizer" import { createSignalTracker, type SignalTracker } from "./signals" diff --git a/packages/opencode/src/telemetry/integration.ts b/packages/opencode/src/telemetry/integration.ts index 2c09fdc2acc..4a2829b5e1e 100644 --- a/packages/opencode/src/telemetry/integration.ts +++ b/packages/opencode/src/telemetry/integration.ts @@ -152,17 +152,18 @@ function subscribeToEvents(state: TelemetryState): void { Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { const { part } = event.properties - // Handle tool calls - if (part.type === "tool" && part.state === "completed") { + // Handle completed tool calls + if (part.type === "tool" && (part.state === "completed" || part.state === "error")) { const duration = part.time?.end && part.time?.start ? part.time.end - part.time.start : 0 + const status = part.state === "completed" ? "success" : "error" collector.recordToolCall( part.tool, - part.state === "completed" ? "success" : "error", + status, duration, part.input ? JSON.stringify(part.input).length : undefined, part.output ? part.output.length : undefined, - undefined, // errorType - would need to parse from output + status === "error" ? "tool_error" : undefined, ) } From 27109136b3038ea9735aac00ad7d68cadaea7945 Mon Sep 17 00:00:00 2001 From: Kenny Heitritter Date: Sat, 24 Jan 2026 00:57:00 -0500 Subject: [PATCH 08/13] feat(telemetry): implement rich multi-turn telemetry collection - Update integration to properly record user messages with content - Record assistant responses with model ID and token counts - Track tool calls with status, duration, and input/output sizes - Finalize turns and upload to telemetry service - Deduplicate user message recording - Get user info (userId, organizationId) from consent endpoint - Read qBraid API key from config (provider.qbraid.options.apiKey) - Update default telemetry endpoint to production Cloud Run URL The telemetry now captures: - Full user message content (sanitized) - Full assistant response content - Tool call metadata (name, status, duration, sizes) - File changes with path hashes - Session metrics (turn count, token counts, tool counts) - Implicit signals (retries, compactions, abandonment) --- packages/opencode/src/config/config.ts | 28 +-- packages/opencode/src/telemetry/collector.ts | 2 +- packages/opencode/src/telemetry/consent.ts | 2 +- packages/opencode/src/telemetry/index.ts | 4 +- .../opencode/src/telemetry/integration.ts | 231 +++++++++++++++--- 5 files changed, 211 insertions(+), 56 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f62078a4a23..c05bf410a51 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -77,18 +77,18 @@ export namespace Config { for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${key}/.well-known/opencode` }) - const response = await fetch(`${key}/.well-known/opencode`) + log.debug("fetching remote config", { url: `${key}/.well-known/codeq` }) + const response = await fetch(`${key}/.well-known/codeq`) if (!response.ok) { throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) } const wellknown = (await response.json()) as any const remoteConfig = wellknown.config ?? {} // Add $schema to prevent load() from trying to write back to a non-existent file - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" + if (!remoteConfig.$schema) remoteConfig.$schema = "https://codeq.ai/config.json" result = mergeConfigConcatArrays( result, - await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`), + await load(JSON.stringify(remoteConfig), `${key}/.well-known/codeq`), ) log.debug("loaded remote config from well-known", { url: key }) } @@ -132,7 +132,7 @@ export namespace Config { // Always scan ~/.opencode/ (user home directory) ...(await Array.fromAsync( Filesystem.up({ - targets: [".opencode"], + targets: [".codeq"], start: Global.Path.home, stop: Global.Path.home, }), @@ -148,7 +148,7 @@ export namespace Config { const deps = [] for (const dir of unique(directories)) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + if (dir.endsWith(".codeq") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.jsonc", "opencode.json"]) { log.debug(`loading config from ${path.join(dir, file)}`) result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) @@ -473,7 +473,7 @@ export namespace Config { * * @example * getPluginName("file:///path/to/plugin/foo.js") // "foo" - * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode" + * getPluginName("oh-my-codeq@2.4.3") // "oh-my-codeq" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ export function getPluginName(plugin: string): string { @@ -500,11 +500,11 @@ export namespace Config { */ export function deduplicatePlugins(plugins: string[]): string[] { // seenNames: canonical plugin names for duplicate detection - // e.g., "oh-my-opencode", "@scope/pkg" + // e.g., "oh-my-codeq", "@scope/pkg" const seenNames = new Set() // uniqueSpecifiers: full plugin specifiers to return - // e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js" + // e.g., "oh-my-codeq@2.4.3", "file:///path/to/plugin.js" const uniqueSpecifiers: string[] = [] for (const specifier of plugins.toReversed()) { @@ -1006,7 +1006,7 @@ export namespace Config { keybinds: Keybinds.optional().describe("Custom keybind configurations"), logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), - server: Server.optional().describe("Server configuration for opencode serve and web commands"), + server: Server.optional().describe("Server configuration for codeq serve and web commands"), command: z .record(z.string(), Command) .optional() @@ -1078,7 +1078,7 @@ export namespace Config { }) .catchall(Agent) .optional() - .describe("Agent configuration, see https://opencode.ai/docs/agents"), + .describe("Agent configuration, see https://codeq.ai/docs/agents"), provider: z .record(z.string(), Provider) .optional() @@ -1184,7 +1184,7 @@ export namespace Config { }) .optional(), // qBraid-specific configuration (CodeQ customizations) - // This section is ignored by upstream opencode and contains qBraid-specific features + // This section is ignored by upstream codeq and contains qBraid-specific features qbraid: z .object({ telemetry: z @@ -1350,9 +1350,9 @@ export namespace Config { const parsed = Info.safeParse(data) if (parsed.success) { if (!parsed.data.$schema) { - parsed.data.$schema = "https://opencode.ai/config.json" + parsed.data.$schema = "https://codeq.ai/config.json" // Write the $schema to the original text to preserve variables like {env:VAR} - const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://codeq.ai/config.json",') await Bun.write(configFilepath, updated).catch(() => {}) } const data = parsed.data diff --git a/packages/opencode/src/telemetry/collector.ts b/packages/opencode/src/telemetry/collector.ts index ee563be069d..69a1cc9712a 100644 --- a/packages/opencode/src/telemetry/collector.ts +++ b/packages/opencode/src/telemetry/collector.ts @@ -343,7 +343,7 @@ export class TelemetryCollector { /** * Finalize the current turn and queue for upload */ - private finalizeTurn(): void { + finalizeTurn(): void { if (!this.sessionState?.currentTurn) return const turn = this.sessionState.currentTurn as TelemetryTurn diff --git a/packages/opencode/src/telemetry/consent.ts b/packages/opencode/src/telemetry/consent.ts index 25a2c21abcb..ce7a10624b9 100644 --- a/packages/opencode/src/telemetry/consent.ts +++ b/packages/opencode/src/telemetry/consent.ts @@ -11,7 +11,7 @@ import type { ConsentStatus, DataLevel, UserTier } from "./types" const log = Log.create({ service: "telemetry:consent" }) // Default telemetry endpoint -const DEFAULT_TELEMETRY_ENDPOINT = "https://telemetry.qbraid.com" +const DEFAULT_TELEMETRY_ENDPOINT = "https://qbraid-telemetry-314301605548.us-central1.run.app" // Cache consent status to avoid repeated API calls let cachedConsent: ConsentStatus | null = null diff --git a/packages/opencode/src/telemetry/index.ts b/packages/opencode/src/telemetry/index.ts index 8beb133dcff..e6a3c7a73d4 100644 --- a/packages/opencode/src/telemetry/index.ts +++ b/packages/opencode/src/telemetry/index.ts @@ -2,7 +2,7 @@ * CodeQ Telemetry Module * * Collects session telemetry for analysis and model improvement. - * This module is qBraid-specific and not part of upstream opencode. + * This module is qBraid-specific and not part of upstream codeq. * * Usage: * import { Telemetry } from "./telemetry" @@ -113,7 +113,7 @@ export namespace Telemetry { /** * Start collecting for a new session * - * @param sessionId - OpenCode session ID + * @param sessionId - CodeQ session ID * @param userId - qBraid user ID * @param organizationId - Organization ID */ diff --git a/packages/opencode/src/telemetry/integration.ts b/packages/opencode/src/telemetry/integration.ts index 4a2829b5e1e..fc0fa9f1744 100644 --- a/packages/opencode/src/telemetry/integration.ts +++ b/packages/opencode/src/telemetry/integration.ts @@ -1,7 +1,7 @@ /** * Telemetry Integration * - * Integrates the telemetry system with OpenCode's Event Bus. + * Integrates the telemetry system with CodeQ's Event Bus. * This module subscribes to relevant events and feeds data to the collector. */ @@ -13,7 +13,11 @@ import { File } from "../file" import { Log } from "../util/log" import { Auth } from "../auth" import { Instance } from "../project/instance" +import { Storage } from "../storage/storage" import { getCollector, initializeTelemetry, shutdownTelemetry } from "./collector" +import path from "path" +import os from "os" +import fs from "fs/promises" const log = Log.create({ service: "telemetry:integration" }) @@ -58,6 +62,50 @@ const getTelemetryState = Instance.state( }, ) +/** + * Get qBraid API key from config or environment + */ +async function getQBraidApiKey(): Promise { + // Try environment variable first + if (process.env.QBRAID_API_KEY) { + return process.env.QBRAID_API_KEY + } + + // Try to get from CodeQ config (provider.qbraid.options.apiKey) + try { + const { Config } = await import("../config/config") + const config = await Config.get() + const apiKey = config.provider?.qbraid?.options?.apiKey + if (apiKey && typeof apiKey === "string") { + return apiKey + } + } catch (error) { + log.debug("could not read qbraid api key from config") + } + + // Fall back to ~/.qbraid/qbraidrc file + try { + const qbraidrcPath = path.join(os.homedir(), ".qbraid", "qbraidrc") + const content = await fs.readFile(qbraidrcPath, "utf-8") + + // Parse INI-style config + for (const line of content.split("\n")) { + const trimmed = line.trim() + if (trimmed.startsWith("api-key")) { + const match = trimmed.match(/api-key\s*=\s*(.+)/) + if (match) { + return match[1].trim() + } + } + } + } catch (error) { + // File doesn't exist or can't be read + log.debug("no qbraidrc file found") + } + + return undefined +} + /** * Initialize telemetry and subscribe to events */ @@ -72,17 +120,55 @@ export async function initTelemetryIntegration(): Promise { // Get auth token if available let authToken: string | undefined + + // First try to get from CodeQ auth system try { const authData = await Auth.all() // Find qBraid auth if available for (const [key, value] of Object.entries(authData)) { - if (key.includes("qbraid") && value.token) { + if (key.includes("qbraid") && value.type === "wellknown" && value.token) { authToken = value.token break } } } catch (error) { - log.debug("no auth token available for telemetry") + log.debug("no auth token in codeq auth system") + } + + // Fall back to qBraid API key from config or qbraidrc + if (!authToken) { + authToken = await getQBraidApiKey() + if (authToken) { + log.debug("using qbraid api key for telemetry") + } + } + + // Fetch user info from consent endpoint before initializing + if (authToken) { + try { + const { Config } = await import("../config/config") + const config = await Config.get() + const endpoint = config.qbraid?.telemetry?.endpoint ?? "https://qbraid-telemetry-314301605548.us-central1.run.app" + + const response = await fetch(`${endpoint}/api/v1/consent`, { + method: "GET", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + }) + + if (response.ok) { + const consentData = await response.json() as { userId: string; organizationId?: string } + cachedUserInfo = { + userId: consentData.userId, + organizationId: consentData.organizationId, + } + log.debug("fetched user info for telemetry", { userId: consentData.userId }) + } + } catch (error) { + log.warn("failed to fetch user info for telemetry", { error }) + } } // Initialize the telemetry system @@ -95,6 +181,9 @@ export async function initTelemetryIntegration(): Promise { log.info("telemetry integration initialized") } +// Store user info from consent endpoint +let cachedUserInfo: { userId: string; organizationId?: string } | null = null + /** * Subscribe to all relevant events */ @@ -107,11 +196,14 @@ function subscribeToEvents(state: TelemetryState): void { const { info } = event.properties log.debug("session created", { sessionId: info.id }) + // Get user ID from cached consent info + const userId = cachedUserInfo?.userId ?? "unknown" + const orgId = cachedUserInfo?.organizationId ?? "unknown" + state.activeSessions.set(info.id, { startTime: Date.now(), - // TODO: Get actual user ID from qBraid auth - userId: "unknown", - orgId: "unknown", + userId, + orgId, }) // Start telemetry session @@ -135,42 +227,118 @@ function subscribeToEvents(state: TelemetryState): void { }), ) + // Track which user messages we've already recorded + const recordedUserMessages = new Set() + // Message updated - track user/assistant messages state.unsubscribers.push( - Bus.subscribe(MessageV2.Event.Updated, (event) => { + Bus.subscribe(MessageV2.Event.Updated, async (event) => { const { info } = event.properties if (info.role === "user") { // User message - start of a turn state.messageStartTimes.set(info.id, Date.now()) + + // Only record each user message once + if (recordedUserMessages.has(info.id)) { + return + } + + // Get user message content from parts + try { + const parts = await MessageV2.parts(info.id) + const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") + const content = textParts.map((p) => p.text).join("\n") + const hasFiles = parts.some((p) => p.type === "file") + + if (content) { + recordedUserMessages.add(info.id) + collector.recordUserMessage(content, false, hasFiles) + log.debug("recorded user message", { messageId: info.id, contentLength: content.length }) + } + } catch (error) { + log.warn("failed to get user message content", { error }) + } } }), ) - // Message part updated - track tool calls and text content + // Message part updated - track tool calls, text content, and step finishes state.unsubscribers.push( Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { const { part } = event.properties // Handle completed tool calls - if (part.type === "tool" && (part.state === "completed" || part.state === "error")) { - const duration = part.time?.end && part.time?.start ? part.time.end - part.time.start : 0 - const status = part.state === "completed" ? "success" : "error" - + if (part.type === "tool" && part.state.status === "completed") { + const toolState = part.state + const duration = toolState.time.end - toolState.time.start + collector.recordToolCall( + part.tool, + "success", + duration, + JSON.stringify(toolState.input).length, + toolState.output.length, + undefined, + ) + log.debug("recorded tool call", { tool: part.tool, duration }) + } else if (part.type === "tool" && part.state.status === "error") { + const toolState = part.state + const duration = toolState.time.end - toolState.time.start collector.recordToolCall( part.tool, - status, + "error", duration, - part.input ? JSON.stringify(part.input).length : undefined, - part.output ? part.output.length : undefined, - status === "error" ? "tool_error" : undefined, + JSON.stringify(toolState.input).length, + undefined, + toolState.error, ) + log.debug("recorded tool error", { tool: part.tool, error: toolState.error }) } - // Handle text parts for assistant messages - if (part.type === "text" && part.time?.end) { - // This is a completed text part - we'll aggregate these - // The full message content will be captured when the message is finalized + // Handle step-finish - this signals end of assistant response + if (part.type === "step-finish") { + // Get the parent message to extract text content + ;(async () => { + try { + const parts = await MessageV2.parts(part.messageID) + const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") + const content = textParts.map((p) => p.text).join("\n") + + // Calculate latency from turn start + const userMessageId = Array.from(state.messageStartTimes.keys()).pop() + const startTime = userMessageId ? state.messageStartTimes.get(userMessageId) : Date.now() + const latencyMs = Date.now() - (startTime ?? Date.now()) + + // Get model and tokens from the message info (more reliable than step-finish) + const messageInfo = await Storage.read(["message", part.sessionID, part.messageID]) + const modelId = messageInfo?.modelID ?? "unknown" + + // Prefer message-level tokens (cumulative), fall back to step-finish tokens + const inputTokens = messageInfo?.tokens?.input ?? part.tokens.input + const outputTokens = messageInfo?.tokens?.output ?? part.tokens.output + + collector.recordAssistantMessage( + content, + modelId, + inputTokens, + outputTokens, + latencyMs, + ) + + // Finalize the turn - this uploads it to the service + collector.finalizeTurn() + + log.debug("recorded assistant message and finalized turn", { + messageId: part.messageID, + modelId, + inputTokens, + outputTokens, + latencyMs, + }) + } catch (error) { + log.warn("failed to record assistant message", { error }) + } + })() } }), ) @@ -184,26 +352,13 @@ function subscribeToEvents(state: TelemetryState): void { ) // File edited event - track file changes + // Note: The event only provides the file path, not the diff + // Detailed diff tracking would need to be done at the tool level state.unsubscribers.push( Bus.subscribe(File.Event.Edited, (event) => { - const { path, diff } = event.properties - - // Count additions and deletions from diff - let additions = 0 - let deletions = 0 - - if (diff) { - const lines = diff.split("\n") - for (const line of lines) { - if (line.startsWith("+") && !line.startsWith("+++")) { - additions++ - } else if (line.startsWith("-") && !line.startsWith("---")) { - deletions++ - } - } - } - - collector.recordFileChange(path, additions, deletions) + const { file } = event.properties + // Record that a file was modified (without detailed line counts) + collector.recordFileChange(file, 0, 0) }), ) From ceaf8ffdbc50c38911603a50dd8c61ea0fb65611 Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Sat, 21 Feb 2026 13:44:09 -0600 Subject: [PATCH 09/13] fix(telemetry): resolve race conditions, duplicate turns, and consent defaults - Rewrite collector to Instance.state-scoped with dataLevel filtering and messageId deduplication to prevent duplicate turn finalizations - Fix integration user-message race by retrying after 50ms when parts are empty; use MessageV2.Event.Updated with time.completed instead of step-finish; clean up turnStartTimes entries; add SIGTERM/beforeExit flush - Fix Session.Event.Diff handler to use Snapshot.FileDiff shape (file, additions, deletions) instead of non-existent type/hunks/path fields - Rewrite consent to default OFF until explicit opt-in; add setLocalConsent and loadLocalConsent for KV store integration; add CODEQ_DISABLE_TELEMETRY env var kill switch via Flag namespace - Export setConsent/loadConsent from telemetry barrel --- packages/opencode/src/flag/flag.ts | 3 + packages/opencode/src/telemetry/collector.ts | 175 +++----- packages/opencode/src/telemetry/consent.ts | 134 +++--- packages/opencode/src/telemetry/index.ts | 18 +- .../opencode/src/telemetry/integration.ts | 402 ++++++++---------- 5 files changed, 311 insertions(+), 421 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index b11058b3405..33c6cad2373 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -51,6 +51,9 @@ export namespace Flag { export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"] + // qBraid / CodeQ flags + export const CODEQ_DISABLE_TELEMETRY = truthy("CODEQ_DISABLE_TELEMETRY") + function number(key: string) { const value = process.env[key] if (!value) return undefined diff --git a/packages/opencode/src/telemetry/collector.ts b/packages/opencode/src/telemetry/collector.ts index 69a1cc9712a..54a30d8b6e4 100644 --- a/packages/opencode/src/telemetry/collector.ts +++ b/packages/opencode/src/telemetry/collector.ts @@ -1,18 +1,18 @@ /** * Telemetry Collector * - * Main module that collects session telemetry by subscribing to the Event Bus. - * Aggregates data and coordinates with sanitizer, signals, and uploader modules. + * Collects session telemetry per-instance. Uses Instance.state so each project + * context gets its own collector that is disposed when the instance shuts down. */ import { Log } from "../util/log" import { Config } from "../config/config" -import { createSanitizer, hashFilePath, getFileExtension } from "./sanitizer" +import { Instance } from "../project/instance" +import { createSanitizer } from "./sanitizer" import { createSignalTracker, type SignalTracker } from "./signals" import { createUploader, type TelemetryUploader } from "./uploader" import { getConsentStatus, getTelemetryEndpoint } from "./consent" import type { - AssistantMessageData, Environment, FileChangeData, ModelUsage, @@ -20,12 +20,10 @@ import type { TelemetrySession, TelemetryTurn, ToolCallData, - UserMessageData, } from "./types" const log = Log.create({ service: "telemetry:collector" }) -// Package version (injected at build time or read from package.json) const CODEQ_VERSION = process.env.npm_package_version ?? "0.0.0" /** @@ -41,10 +39,12 @@ interface SessionState { modelUsage: ModelUsage currentTurnIndex: number currentTurn: Partial | null + /** Tracks which assistant messageIDs have already been finalized */ + finalizedMessages: Set } /** - * Telemetry collector instance + * Telemetry collector — one per Instance (project context) */ export class TelemetryCollector { private uploader: TelemetryUploader | null = null @@ -53,20 +53,16 @@ export class TelemetryCollector { private sessionState: SessionState | null = null private isEnabled = false private authToken: string | null = null - private unsubscribers: (() => void)[] = [] + private dataLevel: "full" | "metrics-only" = "full" constructor() { this.signalTracker = createSignalTracker() this.sanitizer = createSanitizer() } - /** - * Initialize the collector - */ async initialize(authToken?: string): Promise { this.authToken = authToken ?? null - // Check consent const consent = await getConsentStatus(authToken) if (!consent.telemetryEnabled) { @@ -75,18 +71,17 @@ export class TelemetryCollector { return } - // Get config + this.dataLevel = consent.dataLevel + const config = await Config.get() const telemetryConfig = config.qbraid?.telemetry - // Update sanitizer with exclude patterns from config if (telemetryConfig?.excludePatterns) { this.sanitizer = createSanitizer({ excludePatterns: telemetryConfig.excludePatterns, }) } - // Create uploader const endpoint = telemetryConfig?.endpoint ?? getTelemetryEndpoint() if (authToken) { @@ -100,14 +95,8 @@ export class TelemetryCollector { this.isEnabled = true log.info("telemetry initialized", { endpoint, dataLevel: consent.dataLevel }) - - // Subscribe to events - this.subscribeToEvents() } - /** - * Start collecting for a new session - */ async startSession(sessionId: string, userId: string, organizationId: string): Promise { if (!this.isEnabled) return @@ -133,11 +122,11 @@ export class TelemetryCollector { modelUsage: {}, currentTurnIndex: 0, currentTurn: null, + finalizedMessages: new Set(), } this.signalTracker.reset() - // Create session on the service if (this.uploader) { const session: TelemetrySession = { userId, @@ -160,21 +149,15 @@ export class TelemetryCollector { log.debug("session started", { sessionId }) } - /** - * End the current session - */ async endSession(wasExplicitlyEnded = true): Promise { if (!this.isEnabled || !this.sessionState) return - // Finalize any pending turn if (this.sessionState.currentTurn) { this.finalizeTurn() } - // Calculate final duration const durationSeconds = Math.floor((Date.now() - this.sessionState.startedAt.getTime()) / 1000) - // Update session with final state if (this.uploader) { await this.uploader.updateSession({ endedAt: new Date().toISOString(), @@ -196,22 +179,21 @@ export class TelemetryCollector { this.sessionState = null } - /** - * Record the start of a new turn (user message) - */ recordUserMessage(content: string, hasImages = false, hasFiles = false): void { if (!this.isEnabled || !this.sessionState) return this.signalTracker.startTurn() - const consent = getConsentStatus(this.authToken ?? undefined) + // Respect dataLevel: metrics-only skips message content + const sanitizedContent = this.dataLevel === "full" + ? this.sanitizer.sanitizeContent(content) + : "" - // Create new turn this.sessionState.currentTurn = { turnIndex: this.sessionState.currentTurnIndex, createdAt: new Date().toISOString(), userMessage: { - content: this.sanitizer.sanitizeContent(content), + content: sanitizedContent, contentLength: content.length, hasImages, hasFiles, @@ -221,9 +203,6 @@ export class TelemetryCollector { } } - /** - * Record the assistant response - */ recordAssistantMessage( content: string, modelId: string, @@ -233,8 +212,12 @@ export class TelemetryCollector { ): void { if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + const sanitizedContent = this.dataLevel === "full" + ? this.sanitizer.sanitizeContent(content) + : "" + this.sessionState.currentTurn.assistantMessage = { - content: this.sanitizer.sanitizeContent(content), + content: sanitizedContent, contentLength: content.length, modelId, inputTokens, @@ -242,7 +225,6 @@ export class TelemetryCollector { latencyMs, } - // Update model usage if (!this.sessionState.modelUsage[modelId]) { this.sessionState.modelUsage[modelId] = { turns: 0, @@ -254,14 +236,10 @@ export class TelemetryCollector { this.sessionState.modelUsage[modelId].inputTokens += inputTokens this.sessionState.modelUsage[modelId].outputTokens += outputTokens - // Update session metrics this.sessionState.metrics.totalInputTokens += inputTokens this.sessionState.metrics.totalOutputTokens += outputTokens } - /** - * Record a tool call - */ recordToolCall( name: string, status: "success" | "error", @@ -283,7 +261,6 @@ export class TelemetryCollector { this.sessionState.currentTurn.toolCalls?.push(toolCall) - // Update metrics this.sessionState.metrics.toolCallCount++ if (status === "error") { this.sessionState.metrics.toolErrorCount++ @@ -293,16 +270,10 @@ export class TelemetryCollector { } } - /** - * Record a file change - */ recordFileChange(filePath: string, additions: number, deletions: number): void { if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return - // Skip sensitive files - if (this.sanitizer.isSensitiveFile(filePath)) { - return - } + if (this.sanitizer.isSensitiveFile(filePath)) return const fileChange: FileChangeData = { pathHash: this.sanitizer.hashFilePath(filePath), @@ -316,15 +287,11 @@ export class TelemetryCollector { } this.sessionState.currentTurn.fileChanges.push(fileChange) - // Update metrics this.sessionState.metrics.filesModified++ this.sessionState.metrics.linesAdded += additions this.sessionState.metrics.linesDeleted += deletions } - /** - * Record that the current turn was retried - */ recordRetry(): void { if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return @@ -332,114 +299,84 @@ export class TelemetryCollector { this.signalTracker.recordRetry() } - /** - * Record a compaction event - */ recordCompaction(): void { if (!this.isEnabled) return this.signalTracker.recordCompaction() } /** - * Finalize the current turn and queue for upload + * Check if an assistant message has already been finalized (prevents duplicates + * from multiple step-finish events in multi-step tool-call loops). */ - finalizeTurn(): void { - if (!this.sessionState?.currentTurn) return + hasFinalized(messageId: string): boolean { + return this.sessionState?.finalizedMessages.has(messageId) ?? false + } + + /** + * Finalize the current turn and queue for upload. + * Returns false if the turn was incomplete and skipped. + */ + finalizeTurn(messageId?: string): boolean { + if (!this.sessionState?.currentTurn) return false const turn = this.sessionState.currentTurn as TelemetryTurn - // Ensure we have both user and assistant messages if (!turn.userMessage || !turn.assistantMessage) { log.warn("incomplete turn, skipping", { turnIndex: turn.turnIndex }) this.sessionState.currentTurn = null - return + return false + } + + if (messageId) { + this.sessionState.finalizedMessages.add(messageId) } - // Queue for upload if (this.uploader) { this.uploader.addTurn(turn) } - // Update state this.sessionState.metrics.turnCount++ this.sessionState.currentTurnIndex++ this.sessionState.currentTurn = null this.signalTracker.endTurn() + return true } - /** - * Subscribe to Event Bus events - */ - private subscribeToEvents(): void { - // Note: These subscriptions would integrate with the actual Event Bus - // For now, this is a placeholder that shows the intended integration points - - // Example subscriptions (to be wired up with actual Bus events): - // Bus.subscribe("message.updated", this.handleMessageUpdated.bind(this)) - // Bus.subscribe("session.created", this.handleSessionCreated.bind(this)) - // Bus.subscribe("compaction.completed", this.handleCompaction.bind(this)) - - log.debug("event subscriptions registered") - } - - /** - * Unsubscribe from all events - */ - private unsubscribeAll(): void { - for (const unsubscribe of this.unsubscribers) { - unsubscribe() - } - this.unsubscribers = [] - } - - /** - * Detect the environment (local vs qBraid Lab) - */ private detectEnvironment(): Environment { - // Check for qBraid Lab environment indicators - if (process.env.QBRAID_LAB || process.env.JUPYTERHUB_USER) { - return "lab" - } + if (process.env.QBRAID_LAB || process.env.JUPYTERHUB_USER) return "lab" return "local" } - /** - * Shutdown the collector - */ async shutdown(): Promise { - this.unsubscribeAll() - await this.endSession(false) // Treat as abandoned if shutdown without explicit end + await this.endSession(false) } } -// Singleton instance -let collectorInstance: TelemetryCollector | null = null +/** + * Instance-scoped collector state. Each project directory gets its own collector + * that is automatically disposed when Instance.dispose() is called. + */ +const getCollectorState = Instance.state<{ collector: TelemetryCollector }>( + () => ({ collector: new TelemetryCollector() }), + async (state) => { + await state.collector.shutdown() + }, +) /** - * Get or create the telemetry collector instance + * Get the collector for the current Instance context. */ export function getCollector(): TelemetryCollector { - if (!collectorInstance) { - collectorInstance = new TelemetryCollector() - } - return collectorInstance + return getCollectorState().collector } -/** - * Initialize the telemetry system - */ export async function initializeTelemetry(authToken?: string): Promise { const collector = getCollector() await collector.initialize(authToken) } -/** - * Shutdown the telemetry system - */ export async function shutdownTelemetry(): Promise { - if (collectorInstance) { - await collectorInstance.shutdown() - collectorInstance = null - } + const collector = getCollector() + await collector.shutdown() } diff --git a/packages/opencode/src/telemetry/consent.ts b/packages/opencode/src/telemetry/consent.ts index ce7a10624b9..e15aeafb87b 100644 --- a/packages/opencode/src/telemetry/consent.ts +++ b/packages/opencode/src/telemetry/consent.ts @@ -1,34 +1,28 @@ /** * Telemetry Consent * - * Manages user consent for telemetry collection based on tier and preferences. + * Manages user consent for telemetry based on tier, local preferences, and + * the remote consent service. Defaults to OFF unless explicitly enabled by + * the user through the first-run dialog or config. */ import { Log } from "../util/log" import { Config } from "../config/config" +import { Flag } from "../flag/flag" import type { ConsentStatus, DataLevel, UserTier } from "./types" const log = Log.create({ service: "telemetry:consent" }) -// Default telemetry endpoint const DEFAULT_TELEMETRY_ENDPOINT = "https://qbraid-telemetry-314301605548.us-central1.run.app" -// Cache consent status to avoid repeated API calls let cachedConsent: ConsentStatus | null = null let cacheExpiry: number = 0 const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes -/** - * Get the telemetry endpoint from config or default - */ export function getTelemetryEndpoint(): string { - // This will be called after config is loaded return DEFAULT_TELEMETRY_ENDPOINT } -/** - * Fetch consent status from the telemetry service - */ async function fetchConsentFromService( endpoint: string, authToken: string, @@ -47,8 +41,7 @@ async function fetchConsentFromService( return null } - const data = (await response.json()) as ConsentStatus - return data + return (await response.json()) as ConsentStatus } catch (error) { log.error("error fetching consent status", { error }) return null @@ -56,114 +49,101 @@ async function fetchConsentFromService( } /** - * Get the default consent based on config settings + * Local consent value read from the KV store file. + * Set by the first-run dialog or the `Telemetry.setLocalConsent()` API. + * `null` means no local decision has been recorded yet. */ -function getDefaultConsent(config: Config.Info, userId: string): ConsentStatus { - const qbraidConfig = config.qbraid?.telemetry - - // Default tier assumption for local config - const tier: UserTier = "free" - - // Determine if telemetry is enabled - let telemetryEnabled: boolean - if (qbraidConfig?.enabled === true) { - telemetryEnabled = true - } else if (qbraidConfig?.enabled === false) { - telemetryEnabled = false - } else { - // "tier-default" or undefined - use tier-based defaults - telemetryEnabled = tier === "free" // Only enabled by default for free tier - } +let localConsent: boolean | null = null - // Determine data level - const dataLevel: DataLevel = qbraidConfig?.dataLevel ?? "full" +/** + * Set the local consent value (called by the TUI consent dialog). + */ +export function setLocalConsent(enabled: boolean): void { + localConsent = enabled +} - return { - userId, - tier, - telemetryEnabled, - dataLevel, - } +/** + * Load local consent from the KV store file if available. + * This is called once during initialization. + */ +export function loadLocalConsent(value: boolean | null): void { + localConsent = value } /** - * Get the current consent status for the user + * Get the current consent status. * - * This checks: - * 1. Local config overrides (qbraid.telemetry.enabled) - * 2. Cached consent from service - * 3. Fresh consent from telemetry service - * 4. Falls back to tier-based defaults + * Priority order: + * 1. CODEQ_DISABLE_TELEMETRY env var — always wins + * 2. Config `qbraid.telemetry.enabled` — explicit config override + * 3. Remote consent service (for authenticated users) + * 4. Local consent from first-run dialog (KV store) + * 5. Default: OFF (telemetry is opt-in until the user makes a choice) */ export async function getConsentStatus(authToken?: string): Promise { const config = await Config.get() const qbraidConfig = config.qbraid?.telemetry - - // Get user ID from somewhere (placeholder - needs integration with qBraid auth) const userId = "unknown" - // If config explicitly disables telemetry, respect that + // 1. Env var kill switch + if (Flag.CODEQ_DISABLE_TELEMETRY) { + return { userId, tier: "standard", telemetryEnabled: false, dataLevel: "metrics-only" } + } + + // 2. Explicit config override if (qbraidConfig?.enabled === false) { - log.debug("telemetry disabled by config") + return { userId, tier: "standard", telemetryEnabled: false, dataLevel: "metrics-only" } + } + + if (qbraidConfig?.enabled === true) { return { userId, - tier: "standard", // Assume paid tier if they can configure - telemetryEnabled: false, - dataLevel: "metrics-only", + tier: "standard", + telemetryEnabled: true, + dataLevel: qbraidConfig.dataLevel ?? "full", } } - // Try to get from service if we have an auth token + // 3. Remote consent service (authenticated users) if (authToken) { - // Check cache first - if (cachedConsent && Date.now() < cacheExpiry) { - return cachedConsent - } + if (cachedConsent && Date.now() < cacheExpiry) return cachedConsent - // Fetch from service const endpoint = qbraidConfig?.endpoint ?? getTelemetryEndpoint() const serviceConsent = await fetchConsentFromService(endpoint, authToken) if (serviceConsent) { - // Apply local config overrides - if (qbraidConfig?.enabled === true) { - serviceConsent.telemetryEnabled = true - } - if (qbraidConfig?.dataLevel) { - serviceConsent.dataLevel = qbraidConfig.dataLevel - } - - // Cache the result + if (qbraidConfig?.dataLevel) serviceConsent.dataLevel = qbraidConfig.dataLevel + cachedConsent = serviceConsent cacheExpiry = Date.now() + CACHE_TTL_MS - return serviceConsent } } - // Fall back to config-based defaults - return getDefaultConsent(config, userId) + // 4. Local consent from first-run dialog + if (localConsent !== null) { + return { + userId, + tier: "free", + telemetryEnabled: localConsent, + dataLevel: qbraidConfig?.dataLevel ?? "full", + } + } + + // 5. Default: OFF until user makes a choice + return { userId, tier: "free", telemetryEnabled: false, dataLevel: "metrics-only" } } -/** - * Check if telemetry is currently enabled - */ export async function isTelemetryEnabled(authToken?: string): Promise { const consent = await getConsentStatus(authToken) return consent.telemetryEnabled } -/** - * Get the data collection level - */ export async function getDataLevel(authToken?: string): Promise { const consent = await getConsentStatus(authToken) return consent.dataLevel } -/** - * Clear the consent cache (useful for testing or when user changes settings) - */ export function clearConsentCache(): void { cachedConsent = null cacheExpiry = 0 diff --git a/packages/opencode/src/telemetry/index.ts b/packages/opencode/src/telemetry/index.ts index e6a3c7a73d4..ac1d254ae2c 100644 --- a/packages/opencode/src/telemetry/index.ts +++ b/packages/opencode/src/telemetry/index.ts @@ -35,7 +35,7 @@ import { shutdownTelemetry, type TelemetryCollector, } from "./collector" -import { getConsentStatus, isTelemetryEnabled, clearConsentCache } from "./consent" +import { getConsentStatus, isTelemetryEnabled, clearConsentCache, setLocalConsent, loadLocalConsent } from "./consent" import { initTelemetryIntegration, shutdownTelemetryIntegration, @@ -242,6 +242,22 @@ export namespace Telemetry { export function clearCache(): void { clearConsentCache() } + + /** + * Set local consent from the TUI first-run dialog. + * This persists via the KV store and takes effect immediately. + */ + export function setConsent(enabled: boolean): void { + setLocalConsent(enabled) + } + + /** + * Load previously-stored local consent value from KV store. + * Called during TUI initialization. + */ + export function loadConsent(value: boolean | null): void { + loadLocalConsent(value) + } } // Re-export types for convenience diff --git a/packages/opencode/src/telemetry/integration.ts b/packages/opencode/src/telemetry/integration.ts index fc0fa9f1744..73279f357d2 100644 --- a/packages/opencode/src/telemetry/integration.ts +++ b/packages/opencode/src/telemetry/integration.ts @@ -1,8 +1,8 @@ /** * Telemetry Integration * - * Integrates the telemetry system with CodeQ's Event Bus. - * This module subscribes to relevant events and feeds data to the collector. + * Subscribes to the Event Bus to automatically feed data to the collector. + * Uses Instance.state for cleanup and per-instance isolation. */ import { Bus } from "../bus" @@ -13,7 +13,6 @@ import { File } from "../file" import { Log } from "../util/log" import { Auth } from "../auth" import { Instance } from "../project/instance" -import { Storage } from "../storage/storage" import { getCollector, initializeTelemetry, shutdownTelemetry } from "./collector" import path from "path" import os from "os" @@ -22,84 +21,64 @@ import fs from "fs/promises" const log = Log.create({ service: "telemetry:integration" }) /** - * Telemetry state managed by Instance.state for automatic cleanup + * Per-instance telemetry tracking state */ interface TelemetryState { - activeSessions: Map - messageStartTimes: Map + activeSessions: Map + /** Maps user messageID -> timestamp for latency calculation */ + turnStartTimes: Map + /** Maps assistant messageID -> parent user messageID */ + assistantToUser: Map unsubscribers: (() => void)[] initialized: boolean } -/** - * Get or create telemetry state with automatic disposal on instance cleanup - */ const getTelemetryState = Instance.state( () => ({ activeSessions: new Map(), - messageStartTimes: new Map(), + turnStartTimes: new Map(), + assistantToUser: new Map(), unsubscribers: [], initialized: false, }), async (state) => { - // Dispose handler - called when Instance.dispose() is invoked log.info("disposing telemetry state") - - // Unsubscribe from all events - for (const unsub of state.unsubscribers) { - unsub() - } - - // Shutdown telemetry (flushes pending data) + for (const unsub of state.unsubscribers) unsub() await shutdownTelemetry() - - // Clear tracking maps state.activeSessions.clear() - state.messageStartTimes.clear() + state.turnStartTimes.clear() + state.assistantToUser.clear() state.unsubscribers = [] - log.info("telemetry disposed") }, ) +// Cached user info from consent endpoint +let cachedUserInfo: { userId: string; organizationId?: string } | null = null + /** - * Get qBraid API key from config or environment + * Read qBraid API key from env, config, or ~/.qbraid/qbraidrc */ async function getQBraidApiKey(): Promise { - // Try environment variable first - if (process.env.QBRAID_API_KEY) { - return process.env.QBRAID_API_KEY - } + if (process.env.QBRAID_API_KEY) return process.env.QBRAID_API_KEY - // Try to get from CodeQ config (provider.qbraid.options.apiKey) try { const { Config } = await import("../config/config") const config = await Config.get() const apiKey = config.provider?.qbraid?.options?.apiKey - if (apiKey && typeof apiKey === "string") { - return apiKey - } - } catch (error) { + if (apiKey && typeof apiKey === "string") return apiKey + } catch { log.debug("could not read qbraid api key from config") } - // Fall back to ~/.qbraid/qbraidrc file try { const qbraidrcPath = path.join(os.homedir(), ".qbraid", "qbraidrc") const content = await fs.readFile(qbraidrcPath, "utf-8") - - // Parse INI-style config for (const line of content.split("\n")) { - const trimmed = line.trim() - if (trimmed.startsWith("api-key")) { - const match = trimmed.match(/api-key\s*=\s*(.+)/) - if (match) { - return match[1].trim() - } - } + const match = line.trim().match(/^api-key\s*=\s*(.+)/) + if (match) return match[1].trim() } - } catch (error) { - // File doesn't exist or can't be read + } catch { log.debug("no qbraidrc file found") } @@ -111,45 +90,39 @@ async function getQBraidApiKey(): Promise { */ export async function initTelemetryIntegration(): Promise { const state = getTelemetryState() - - // Avoid double initialization if (state.initialized) { log.debug("telemetry already initialized") return } - // Get auth token if available let authToken: string | undefined - // First try to get from CodeQ auth system + // Try CodeQ auth system first try { const authData = await Auth.all() - // Find qBraid auth if available for (const [key, value] of Object.entries(authData)) { if (key.includes("qbraid") && value.type === "wellknown" && value.token) { authToken = value.token break } } - } catch (error) { + } catch { log.debug("no auth token in codeq auth system") } - // Fall back to qBraid API key from config or qbraidrc + // Fall back to qBraid API key if (!authToken) { authToken = await getQBraidApiKey() - if (authToken) { - log.debug("using qbraid api key for telemetry") - } + if (authToken) log.debug("using qbraid api key for telemetry") } - // Fetch user info from consent endpoint before initializing + // Fetch user info from consent endpoint if (authToken) { try { const { Config } = await import("../config/config") const config = await Config.get() const endpoint = config.qbraid?.telemetry?.endpoint ?? "https://qbraid-telemetry-314301605548.us-central1.run.app" - + const response = await fetch(`${endpoint}/api/v1/consent`, { method: "GET", headers: { @@ -157,69 +130,54 @@ export async function initTelemetryIntegration(): Promise { "Content-Type": "application/json", }, }) - + if (response.ok) { - const consentData = await response.json() as { userId: string; organizationId?: string } - cachedUserInfo = { - userId: consentData.userId, - organizationId: consentData.organizationId, - } - log.debug("fetched user info for telemetry", { userId: consentData.userId }) + const data = await response.json() as { userId: string; organizationId?: string } + cachedUserInfo = { userId: data.userId, organizationId: data.organizationId } + log.debug("fetched user info for telemetry", { userId: data.userId }) } } catch (error) { log.warn("failed to fetch user info for telemetry", { error }) } } - // Initialize the telemetry system await initializeTelemetry(authToken) - - // Subscribe to session events subscribeToEvents(state) - state.initialized = true + + // Flush pending data on process exit + const flushOnExit = () => { + shutdownTelemetry().catch(() => {}) + } + process.once("SIGTERM", flushOnExit) + process.once("beforeExit", flushOnExit) + log.info("telemetry integration initialized") } -// Store user info from consent endpoint -let cachedUserInfo: { userId: string; organizationId?: string } | null = null - /** - * Subscribe to all relevant events + * Subscribe to Bus events and feed data to the collector. */ function subscribeToEvents(state: TelemetryState): void { const collector = getCollector() - // Session created - start tracking + // --- Session lifecycle --- + state.unsubscribers.push( Bus.subscribe(Session.Event.Created, async (event) => { const { info } = event.properties - log.debug("session created", { sessionId: info.id }) - - // Get user ID from cached consent info const userId = cachedUserInfo?.userId ?? "unknown" const orgId = cachedUserInfo?.organizationId ?? "unknown" - state.activeSessions.set(info.id, { - startTime: Date.now(), - userId, - orgId, - }) - - // Start telemetry session - const sessionData = state.activeSessions.get(info.id) - if (sessionData) { - await collector.startSession(info.id, sessionData.userId ?? "unknown", sessionData.orgId ?? "unknown") - } + state.activeSessions.set(info.id, { startTime: Date.now(), userId, orgId }) + await collector.startSession(info.id, userId, orgId) + log.debug("session tracking started", { sessionId: info.id }) }), ) - // Session deleted - end tracking state.unsubscribers.push( Bus.subscribe(Session.Event.Deleted, async (event) => { const { info } = event.properties - log.debug("session deleted", { sessionId: info.id }) - if (state.activeSessions.has(info.id)) { await collector.endSession(true) state.activeSessions.delete(info.id) @@ -227,150 +185,170 @@ function subscribeToEvents(state: TelemetryState): void { }), ) - // Track which user messages we've already recorded - const recordedUserMessages = new Set() - - // Message updated - track user/assistant messages - state.unsubscribers.push( - Bus.subscribe(MessageV2.Event.Updated, async (event) => { - const { info } = event.properties - - if (info.role === "user") { - // User message - start of a turn - state.messageStartTimes.set(info.id, Date.now()) - - // Only record each user message once - if (recordedUserMessages.has(info.id)) { - return - } + // --- User messages --- + // We record user messages when we see a text part on a user message. + // MessageV2.Event.PartUpdated fires *after* the part is written to storage, + // avoiding the race where MessageV2.Event.Updated fires before parts exist. - // Get user message content from parts - try { - const parts = await MessageV2.parts(info.id) - const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") - const content = textParts.map((p) => p.text).join("\n") - const hasFiles = parts.some((p) => p.type === "file") - - if (content) { - recordedUserMessages.add(info.id) - collector.recordUserMessage(content, false, hasFiles) - log.debug("recorded user message", { messageId: info.id, contentLength: content.length }) - } - } catch (error) { - log.warn("failed to get user message content", { error }) - } - } - }), - ) + const recordedUserMessages = new Set() - // Message part updated - track tool calls, text content, and step finishes state.unsubscribers.push( Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { const { part } = event.properties + // Record user message text parts (deduped per message) + if (part.type === "text" && !recordedUserMessages.has(part.messageID)) { + // Check if this part belongs to a user message by looking up the message + // We defer this to the Updated event for user messages to avoid extra reads + } + // Handle completed tool calls if (part.type === "tool" && part.state.status === "completed") { - const toolState = part.state - const duration = toolState.time.end - toolState.time.start + const duration = part.state.time.end - part.state.time.start collector.recordToolCall( part.tool, "success", duration, - JSON.stringify(toolState.input).length, - toolState.output.length, + JSON.stringify(part.state.input).length, + part.state.output.length, undefined, ) - log.debug("recorded tool call", { tool: part.tool, duration }) } else if (part.type === "tool" && part.state.status === "error") { - const toolState = part.state - const duration = toolState.time.end - toolState.time.start + const duration = part.state.time.end - part.state.time.start collector.recordToolCall( part.tool, "error", duration, - JSON.stringify(toolState.input).length, + JSON.stringify(part.state.input).length, undefined, - toolState.error, + part.state.error, ) - log.debug("recorded tool error", { tool: part.tool, error: toolState.error }) } + }), + ) - // Handle step-finish - this signals end of assistant response - if (part.type === "step-finish") { - // Get the parent message to extract text content - ;(async () => { + // --- Message updated: captures user messages and assistant completion --- + + state.unsubscribers.push( + Bus.subscribe(MessageV2.Event.Updated, async (event) => { + const { info } = event.properties + + if (info.role === "user") { + // Record the turn start time + state.turnStartTimes.set(info.id, Date.now()) + + // Only record content once per message + if (recordedUserMessages.has(info.id)) return + recordedUserMessages.add(info.id) + + // Read parts — by the time Updated fires for a user message on subsequent + // updates (e.g. when the assistant starts), parts should be available. + // We retry once after a short delay as a safety net. + let content = "" + let hasFiles = false + try { + const parts = await MessageV2.parts(info.id) + const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") + content = textParts.map((p) => p.text).join("\n") + hasFiles = parts.some((p) => p.type === "file") + } catch { + // Parts may not be written yet on the very first Updated event. + // Re-try after a short delay. + await new Promise((r) => setTimeout(r, 50)) try { - const parts = await MessageV2.parts(part.messageID) + const parts = await MessageV2.parts(info.id) const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") - const content = textParts.map((p) => p.text).join("\n") - - // Calculate latency from turn start - const userMessageId = Array.from(state.messageStartTimes.keys()).pop() - const startTime = userMessageId ? state.messageStartTimes.get(userMessageId) : Date.now() - const latencyMs = Date.now() - (startTime ?? Date.now()) - - // Get model and tokens from the message info (more reliable than step-finish) - const messageInfo = await Storage.read(["message", part.sessionID, part.messageID]) - const modelId = messageInfo?.modelID ?? "unknown" - - // Prefer message-level tokens (cumulative), fall back to step-finish tokens - const inputTokens = messageInfo?.tokens?.input ?? part.tokens.input - const outputTokens = messageInfo?.tokens?.output ?? part.tokens.output - - collector.recordAssistantMessage( - content, - modelId, - inputTokens, - outputTokens, - latencyMs, - ) - - // Finalize the turn - this uploads it to the service - collector.finalizeTurn() - - log.debug("recorded assistant message and finalized turn", { - messageId: part.messageID, - modelId, - inputTokens, - outputTokens, - latencyMs, - }) + content = textParts.map((p) => p.text).join("\n") + hasFiles = parts.some((p) => p.type === "file") } catch (error) { - log.warn("failed to record assistant message", { error }) + log.warn("failed to get user message parts after retry", { error }) } - })() + } + + if (content) { + collector.recordUserMessage(content, false, hasFiles) + log.debug("recorded user message", { messageId: info.id, len: content.length }) + } + } + + // Assistant message with time.completed set means the processor is done + // with this message. This fires exactly once per full response cycle. + if (info.role === "assistant" && info.time?.completed) { + // Guard against duplicate finalization + if (collector.hasFinalized(info.id)) return + + try { + const parts = await MessageV2.parts(info.id) + const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") + const content = textParts.map((p) => p.text).join("\n") + + // Find the user message that started this turn. + // The most recent entry in turnStartTimes is the current turn's user message. + let startTime = Date.now() + const entries = Array.from(state.turnStartTimes.entries()) + if (entries.length > 0) { + const last = entries[entries.length - 1] + startTime = last[1] + // Clean up old entries to prevent unbounded growth + state.turnStartTimes.delete(last[0]) + } + + const latencyMs = Date.now() - startTime + const modelId = info.modelID ?? "unknown" + const inputTokens = info.tokens?.input ?? 0 + const outputTokens = info.tokens?.output ?? 0 + + collector.recordAssistantMessage(content, modelId, inputTokens, outputTokens, latencyMs) + collector.finalizeTurn(info.id) + + log.debug("finalized turn", { + messageId: info.id, + modelId, + inputTokens, + outputTokens, + latencyMs, + }) + } catch (error) { + log.warn("failed to finalize turn", { error }) + } } }), ) - // Compaction event + // --- Compaction --- + state.unsubscribers.push( - Bus.subscribe(SessionCompaction.Event.Compacted, (event) => { - log.debug("compaction occurred", { sessionId: event.properties.sessionID }) + Bus.subscribe(SessionCompaction.Event.Compacted, () => { collector.recordCompaction() }), ) - // File edited event - track file changes - // Note: The event only provides the file path, not the diff - // Detailed diff tracking would need to be done at the tool level + // --- File edits --- + state.unsubscribers.push( Bus.subscribe(File.Event.Edited, (event) => { - const { file } = event.properties - // Record that a file was modified (without detailed line counts) - collector.recordFileChange(file, 0, 0) + collector.recordFileChange(event.properties.file, 0, 0) }), ) - // Session error event + // --- Session diff (for line-level change data) --- + + state.unsubscribers.push( + Bus.subscribe(Session.Event.Diff, (event) => { + for (const diff of event.properties.diff) { + if (diff.additions > 0 || diff.deletions > 0) { + collector.recordFileChange(diff.file, diff.additions, diff.deletions) + } + } + }), + ) + + // --- Session errors --- + state.unsubscribers.push( Bus.subscribe(Session.Event.Error, (event) => { - const { error } = event.properties - if (error) { - // Record error in signals - const collector = getCollector() - // The collector tracks errors internally via recordToolCall with error status - log.debug("session error", { error: error.name }) + if (event.properties.error) { + log.debug("session error", { error: event.properties.error.name }) } }), ) @@ -379,68 +357,44 @@ function subscribeToEvents(state: TelemetryState): void { } /** - * Finalize a turn when assistant response is complete - * - * Called from the session processor when a message is fully processed. + * Finalize a turn manually (for non-Event-Bus callers). */ export function finalizeTurn( - sessionId: string, + _sessionId: string, assistantContent: string, modelId: string, tokens: { input: number; output: number }, startTime?: number, ): void { const collector = getCollector() - - // Calculate latency const latencyMs = startTime ? Date.now() - startTime : 0 - - // Record the assistant message collector.recordAssistantMessage(assistantContent, modelId, tokens.input, tokens.output, latencyMs) } -/** - * Record that a user message was sent - */ export function recordUserTurn(content: string, hasImages = false, hasFiles = false): void { - const collector = getCollector() - collector.recordUserMessage(content, hasImages, hasFiles) + getCollector().recordUserMessage(content, hasImages, hasFiles) } -/** - * Record that a turn was retried - */ export function recordRetry(): void { - const collector = getCollector() - collector.recordRetry() + getCollector().recordRetry() } /** - * Shutdown telemetry and unsubscribe from events - * - * Note: This is normally handled automatically by Instance.dispose() - * via the state disposal mechanism. This function is provided for - * explicit shutdown in non-standard scenarios. + * Shutdown telemetry integration explicitly. + * Normally handled automatically by Instance.dispose() via state disposal. */ export async function shutdownTelemetryIntegration(): Promise { const state = getTelemetryState() + if (!state.initialized) return - if (!state.initialized) { - return - } - - // Unsubscribe from all events - for (const unsub of state.unsubscribers) { - unsub() - } + for (const unsub of state.unsubscribers) unsub() state.unsubscribers = [] - // Shutdown telemetry (flushes pending data) await shutdownTelemetry() - // Clear tracking maps state.activeSessions.clear() - state.messageStartTimes.clear() + state.turnStartTimes.clear() + state.assistantToUser.clear() state.initialized = false log.info("telemetry integration shutdown") From 2a305f11c9431e5a2ad538b1abacdaaa5a592582 Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Sat, 21 Feb 2026 13:44:26 -0600 Subject: [PATCH 10/13] feat(telemetry): add first-run consent dialog with tier-aware UX - Add DialogTelemetryConsent component: free-tier shows informational 'I Understand' (forced opt-in); paid-tier shows 'Enable'/'No Thanks' two-button confirm/decline with keyboard navigation - Persist consent choice to KV store (telemetry_consent_shown, telemetry_enabled) and propagate to Telemetry.setConsent() - Wire dialog into app.tsx via createEffect that fires before the connect-provider dialog, checking KV for prior consent --- packages/opencode/src/cli/cmd/tui/app.tsx | 29 ++++ .../component/dialog-telemetry-consent.tsx | 138 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7442037604b..de9bad85220 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -36,6 +36,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" +import { DialogTelemetryConsent, KV_TELEMETRY_CONSENT_SHOWN, KV_TELEMETRY_ENABLED } from "@tui/component/dialog-telemetry-consent" +import { Telemetry } from "@/telemetry" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -272,6 +274,33 @@ function App() { } }) + // --- First-run telemetry consent dialog --- + // Fires once when sync is complete and user hasn't seen the consent dialog yet. + // Must fire *before* the provider connect dialog so consent is captured first. + createEffect( + on( + () => sync.status === "complete" && kv.get(KV_TELEMETRY_CONSENT_SHOWN) === undefined, + (needsConsent, prev) => { + if (!needsConsent || prev) return + + // Load any existing consent value into the telemetry module + const existing = kv.get(KV_TELEMETRY_ENABLED) + if (existing !== undefined) { + Telemetry.loadConsent(existing as boolean) + // Already consented in a previous session, skip dialog + kv.set(KV_TELEMETRY_CONSENT_SHOWN, true) + return + } + + // Show the consent dialog. Free tier = informational only. + // TODO: Determine actual tier from auth/consent service. Default to "free". + dialog.replace(() => ( + {}} /> + )) + }, + ), + ) + createEffect( on( () => sync.status === "complete" && sync.data.provider.length === 0, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx new file mode 100644 index 00000000000..b28cf95576b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx @@ -0,0 +1,138 @@ +/** + * First-run telemetry consent dialog. + * + * - Free-tier users: informational only — telemetry is required, single "I Understand" button. + * - Paid/unknown users: genuine opt-in with "Enable" / "No Thanks" buttons. + * + * The consent choice is persisted to the KV store and loaded into the + * Telemetry consent module on startup. + */ + +import { TextAttributes } from "@opentui/core" +import { useTheme } from "@tui/context/theme" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { useKV } from "@tui/context/kv" +import { createStore } from "solid-js/store" +import { For, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import { Telemetry } from "@/telemetry" + +/** KV keys used by the consent dialog */ +export const KV_TELEMETRY_CONSENT_SHOWN = "telemetry_consent_shown" +export const KV_TELEMETRY_ENABLED = "telemetry_enabled" + +type Tier = "free" | "paid" + +export type DialogTelemetryConsentProps = { + tier: Tier + onResult: (accepted: boolean) => void +} + +export function DialogTelemetryConsent(props: DialogTelemetryConsentProps) { + const dialog = useDialog() + const { theme } = useTheme() + const kv = useKV() + + const isFree = () => props.tier === "free" + + const title = () => isFree() ? "Usage Data Collection" : "Enable Usage Telemetry?" + + const message = () => + isFree() + ? "CodeQ collects anonymous usage telemetry to improve the product.\n" + + "This includes session metrics (token counts, tool usage, latency)\n" + + "and is required for free-tier accounts. No source code or secrets\n" + + "are collected. You can review our privacy policy at qbraid.com/privacy." + : "CodeQ can collect anonymous usage telemetry to help us improve\n" + + "the product. This includes session metrics like token counts,\n" + + "tool usage, and latency. No source code or secrets are collected.\n\n" + + "You can change this anytime in your config:\n" + + ' qbraid.telemetry.enabled: true | false' + + // Free tier: single button. Paid tier: two-button confirm/decline. + const buttons = () => + isFree() ? ["understand"] as const : ["decline", "enable"] as const + + const [store, setStore] = createStore({ + active: isFree() ? "understand" : "enable", + }) + + const labels: Record = { + understand: "I Understand", + enable: "Enable", + decline: "No Thanks", + } + + const handleSelect = (key: string) => { + const accepted = key === "understand" || key === "enable" + kv.set(KV_TELEMETRY_CONSENT_SHOWN, true) + kv.set(KV_TELEMETRY_ENABLED, accepted) + Telemetry.setConsent(accepted) + props.onResult(accepted) + dialog.clear() + } + + useKeyboard((evt) => { + if (evt.name === "return") { + handleSelect(store.active) + return + } + + if (!isFree() && (evt.name === "left" || evt.name === "right")) { + setStore("active", store.active === "enable" ? "decline" : "enable") + } + }) + + return ( + + + + {title()} + + + esc + + + + {message()} + + + + {(key) => ( + handleSelect(key)} + > + + {labels[key]} + + + )} + + + + ) +} + +/** + * Show the consent dialog and return a promise that resolves with the user's choice. + */ +DialogTelemetryConsent.show = ( + dialog: DialogContext, + tier: Tier, +): Promise => { + return new Promise((resolve) => { + dialog.replace( + () => ( + resolve(accepted)} + /> + ), + // If user presses Esc on paid tier, treat as decline + () => resolve(false), + ) + }) +} From 780b18cef4fc27d1a1221dfd6c96d07e1df5e131 Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Sat, 21 Feb 2026 13:44:35 -0600 Subject: [PATCH 11/13] feat(branding): restore upstream providers alongside qBraid defaults - Change brand.json models from exclusive:true to exclusive:false, default:true so qBraid models are prepended but models.dev catalog and all upstream providers (Anthropic, OpenAI, Copilot, Codex) remain - Add 'default' boolean field to branding ModelsSchema - Update apply.ts models.ts transform: when exclusive=false, prepend branded models and merge with models.dev; only clear CUSTOM_LOADERS and BUILTIN plugin arrays when exclusive=true --- branding/apply.ts | 68 ++++++++++++++++++++++++++++---------- branding/qbraid/brand.json | 3 +- branding/schema.ts | 4 ++- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/branding/apply.ts b/branding/apply.ts index b5827e9083f..9c4b6bc00f1 100644 --- a/branding/apply.ts +++ b/branding/apply.ts @@ -342,14 +342,14 @@ const PURPLE = RGBA.fromHex("#9370DB")`, }, }, - // Model provider configuration (remove Zen, add qBraid) - // This replaces the entire models.ts to use embedded models + // Model provider configuration + // When exclusive=true: replace get() to return only embedded models (original behavior) + // When default=true, exclusive=false: prepend branded models to the models.dev response { pattern: "packages/opencode/src/provider/models.ts", transform: async (content, config) => { - if (!config.models?.exclusive || !config.models?.source) return content + if (!config.models?.source) return content - // Read the models JSON const modelsPath = path.join(BRAND_DIR, config.models.source.replace("./", "")) const modelsFile = Bun.file(modelsPath) if (!(await modelsFile.exists())) { @@ -358,16 +358,53 @@ const PURPLE = RGBA.fromHex("#9370DB")`, } const modelsJson = await modelsFile.json() - // Remove schema and comment keys delete modelsJson.$schema delete modelsJson._comment - // Replace the get() function with one that returns embedded models directly + if (config.models.exclusive) { + // Exclusive mode: only branded models, no models.dev fetch + return content.replace( + /export async function get\(\) \{[\s\S]*?\n \}/, + `export async function get() { + // Branding: embedded models (exclusive mode, no external fetch) + return ${JSON.stringify(modelsJson)} as Record + }`, + ) + } + + // Default mode: prepend branded models, then merge models.dev data + // This ensures qBraid models appear first and are the defaults, + // while all upstream providers (Anthropic, OpenAI, Copilot, Codex, etc.) + // remain available. + const brandedModelsStr = JSON.stringify(modelsJson) return content.replace( /export async function get\(\) \{[\s\S]*?\n \}/, `export async function get() { - // Branding: embedded models (no external fetch) - return ${JSON.stringify(modelsJson)} as Record + // Branding: qBraid models prepended as defaults + const branded = ${brandedModelsStr} as Record + + // Fetch upstream models from models.dev (or cache) + let upstream: Record = {} + try { + const cached = await readCache() + if (cached) { + upstream = cached + } else { + const response = await fetch(url) + if (response.ok) { + upstream = await response.json() as Record + await writeCache(upstream) + } + } + } catch (e) { + // Fall back to bundled snapshot if fetch fails + try { + upstream = (await import("./models-snapshot")).default as Record + } catch {} + } + + // Merge: branded providers first, then upstream (branded wins on conflict) + return { ...upstream, ...branded } }`, ) }, @@ -386,14 +423,14 @@ const PURPLE = RGBA.fromHex("#9370DB")`, }, }, - // Remove builtin plugins (they don't exist for qBraid branding) + // Remove builtin plugins only in exclusive mode. + // In default mode (exclusive=false), keep plugins so Anthropic auth, + // Codex OAuth, Copilot device code, etc. continue to work. { pattern: "packages/opencode/src/plugin/index.ts", transform: (content, config) => { if (!config.models?.exclusive) return content - // Clear the BUILTIN array - these npm packages don't exist for branded versions - // Match the array with its contents across potential newlines return content.replace( /const BUILTIN = \["[^"]*"(?:,\s*"[^"]*")*\]/, "const BUILTIN: string[] = [] // Cleared by branding - no external plugins", @@ -401,17 +438,14 @@ const PURPLE = RGBA.fromHex("#9370DB")`, }, }, - // Remove custom loaders for providers that don't exist in exclusive models + // Remove custom loaders only in exclusive mode. + // In default mode, keep all loaders — they're needed for native provider support + // (Anthropic, OpenAI, Bedrock, Copilot, etc.). { pattern: "packages/opencode/src/provider/provider.ts", transform: (content, config) => { if (!config.models?.exclusive) return content - // Comment out all custom loaders when in exclusive mode - // This prevents "Provider does not exist in model list" errors - // Match the CUSTOM_LOADERS object definition and replace with empty object - // The object starts at "const CUSTOM_LOADERS: Record = {" - // and ends with " }" before "export const Model" return content.replace( /const CUSTOM_LOADERS: Record = \{[\s\S]*?\n \}(?=\n\n export const Model)/, `const CUSTOM_LOADERS: Record = { diff --git a/branding/qbraid/brand.json b/branding/qbraid/brand.json index d99336614e0..33f73edabc9 100644 --- a/branding/qbraid/brand.json +++ b/branding/qbraid/brand.json @@ -29,7 +29,8 @@ } }, "models": { - "exclusive": true, + "default": true, + "exclusive": false, "removeProviders": ["opencode"], "source": "./models.json" }, diff --git a/branding/schema.ts b/branding/schema.ts index abfac367857..91548c91683 100644 --- a/branding/schema.ts +++ b/branding/schema.ts @@ -45,8 +45,10 @@ export const ModelsSchema = z.object({ .optional(), /** Provider IDs to completely remove */ removeProviders: z.array(z.string()).optional(), - /** If true, only use the providers defined in this config */ + /** If true, only use the providers defined in this config (locks out all others) */ exclusive: z.boolean().optional(), + /** If true, branded models are prepended as defaults but upstream providers remain available */ + default: z.boolean().optional(), }) /** From 51d6d74cc1c400aa2bf2cfcc2b5b8cb61cd14833 Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Sat, 21 Feb 2026 13:44:45 -0600 Subject: [PATCH 12/13] feat(quantum): add native quantum computing tools replacing pod_mcp - Add TypeScript HTTP client for qBraid quantum API with auth resolution chain (env var > CodeQ auth store > ~/.qbraid/qbraidrc) - Define 6 in-process tools: quantum_devices, quantum_estimate_cost, quantum_submit_job, quantum_get_result, quantum_cancel_job, quantum_list_jobs - Job submission uses ctx.ask() permission system for cost approval, replacing pod_mcp's fragile cost_reviewed_and_approved boolean - Register quantum tools in tool registry - Tightly integrated with CodeQ auth, permissions, and telemetry; not portable to other agents by design --- packages/opencode/src/quantum/client.ts | 251 ++++++++++++++++++++ packages/opencode/src/quantum/index.ts | 13 ++ packages/opencode/src/quantum/tools.ts | 290 ++++++++++++++++++++++++ packages/opencode/src/tool/registry.ts | 2 + 4 files changed, 556 insertions(+) create mode 100644 packages/opencode/src/quantum/client.ts create mode 100644 packages/opencode/src/quantum/index.ts create mode 100644 packages/opencode/src/quantum/tools.ts diff --git a/packages/opencode/src/quantum/client.ts b/packages/opencode/src/quantum/client.ts new file mode 100644 index 00000000000..4866536e4b2 --- /dev/null +++ b/packages/opencode/src/quantum/client.ts @@ -0,0 +1,251 @@ +/** + * qBraid Quantum API Client + * + * TypeScript HTTP client for the qBraid quantum runtime API. + * Replaces the Python SDK dependency for device listing, job submission, + * and result retrieval — keeping everything in-process. + */ + +import { Log } from "../util/log" +import { Auth } from "../auth" +import path from "path" +import os from "os" +import fs from "fs/promises" + +const log = Log.create({ service: "quantum:client" }) + +const DEFAULT_API_URL = "https://api-v2.qbraid.com/api/v1" + +export interface QuantumDevice { + id: string + name: string + vendor: string + provider: string + type: string + status: string + qubits: number + paradigm: string + pricing?: { + perShot?: number + perTask?: number + perMinute?: number + } +} + +export interface QuantumJob { + id: string + device: string + status: string + shots: number + createdAt: string + endedAt?: string + cost?: number +} + +export interface JobResult { + jobId: string + status: string + measurements?: Record + success: boolean +} + +export interface CostEstimate { + deviceId: string + shots: number + estimatedCredits: number + breakdown: { + perShot: number + perTask: number + } +} + +/** + * Resolve the qBraid API key and base URL. + * Priority: env var > config provider > ~/.qbraid/qbraidrc + */ +async function resolveAuth(): Promise<{ apiKey: string; baseUrl: string } | null> { + let apiKey: string | undefined + let baseUrl = DEFAULT_API_URL + + // 1. Environment variable + if (process.env.QBRAID_API_KEY) { + apiKey = process.env.QBRAID_API_KEY + } + + // 2. CodeQ auth store + if (!apiKey) { + try { + const authData = await Auth.all() + for (const [key, value] of Object.entries(authData)) { + if (key.includes("qbraid")) { + if (value.type === "wellknown" && value.token) { + apiKey = value.token + break + } + if (value.type === "api" && value.key) { + apiKey = value.key + break + } + } + } + } catch { + // auth not available + } + } + + // 3. ~/.qbraid/qbraidrc + if (!apiKey) { + try { + const rcPath = path.join(os.homedir(), ".qbraid", "qbraidrc") + const content = await fs.readFile(rcPath, "utf-8") + for (const line of content.split("\n")) { + const keyMatch = line.trim().match(/^api-key\s*=\s*(.+)/) + if (keyMatch) apiKey = keyMatch[1].trim() + const urlMatch = line.trim().match(/^url\s*=\s*(.+)/) + if (urlMatch) baseUrl = urlMatch[1].trim() + } + } catch { + // qbraidrc not available + } + } + + if (process.env.QBRAID_API_BASE_URL) { + baseUrl = process.env.QBRAID_API_BASE_URL + } + + if (!apiKey) return null + return { apiKey, baseUrl } +} + +async function request(method: string, endpoint: string, body?: unknown): Promise { + const auth = await resolveAuth() + if (!auth) throw new Error("No qBraid API key found. Run `codeq /connect` to set up qBraid.") + + const url = `${auth.baseUrl}${endpoint}` + log.debug("quantum api request", { method, url }) + + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + "api-key": auth.apiKey, + }, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!response.ok) { + const text = await response.text().catch(() => "") + throw new Error(`qBraid API ${method} ${endpoint} failed (${response.status}): ${text}`) + } + + return response.json() as Promise +} + +/** + * List available quantum devices with optional filters. + */ +export async function listDevices(filters?: { + status?: string + provider?: string +}): Promise { + const params = new URLSearchParams() + if (filters?.status) params.set("status", filters.status) + if (filters?.provider) params.set("provider", filters.provider) + + const query = params.toString() + const endpoint = `/quantum/devices${query ? `?${query}` : ""}` + const data = await request("GET", endpoint) + return Array.isArray(data) ? data : data.devices ?? [] +} + +/** + * Get details for a specific device. + */ +export async function getDevice(deviceId: string): Promise { + return request("GET", `/quantum/devices/${encodeURIComponent(deviceId)}`) +} + +/** + * Estimate the cost of running a job on a device. + */ +export async function estimateCost(deviceId: string, shots: number): Promise { + const device = await getDevice(deviceId) + const perShot = device.pricing?.perShot ?? 0 + const perTask = device.pricing?.perTask ?? 0 + const estimatedCredits = perShot * shots + perTask + + return { + deviceId, + shots, + estimatedCredits, + breakdown: { perShot: perShot * shots, perTask }, + } +} + +/** + * Submit a QASM circuit to a device. + */ +export async function submitJob(params: { + deviceId: string + qasm: string + shots: number +}): Promise { + return request("POST", "/quantum/jobs", { + device: params.deviceId, + openQasm: params.qasm, + shots: params.shots, + }) +} + +/** + * Get the status and metadata of a job. + */ +export async function getJob(jobId: string): Promise { + return request("GET", `/quantum/jobs/${encodeURIComponent(jobId)}`) +} + +/** + * Get the results of a completed job. + */ +export async function getResult(jobId: string): Promise { + return request("GET", `/quantum/jobs/${encodeURIComponent(jobId)}/result`) +} + +/** + * Cancel a running or queued job. + */ +export async function cancelJob(jobId: string): Promise<{ success: boolean }> { + return request<{ success: boolean }>("POST", `/quantum/jobs/${encodeURIComponent(jobId)}/cancel`) +} + +/** + * List recent jobs with optional filters. + */ +export async function listJobs(filters?: { + status?: string + limit?: number +}): Promise { + const params = new URLSearchParams() + if (filters?.status) params.set("status", filters.status) + if (filters?.limit) params.set("limit", String(filters.limit)) + + const query = params.toString() + const endpoint = `/quantum/jobs${query ? `?${query}` : ""}` + const data = await request("GET", endpoint) + return Array.isArray(data) ? data : data.jobs ?? [] +} + +/** + * Get account credit balance. + */ +export async function getCredits(): Promise<{ balance: number }> { + return request<{ balance: number }>("GET", "/user/credits") +} + +/** + * Check if qBraid API access is configured. + */ +export async function isConfigured(): Promise { + const auth = await resolveAuth() + return auth !== null +} diff --git a/packages/opencode/src/quantum/index.ts b/packages/opencode/src/quantum/index.ts new file mode 100644 index 00000000000..6c94f21d3fe --- /dev/null +++ b/packages/opencode/src/quantum/index.ts @@ -0,0 +1,13 @@ +/** + * Quantum Module + * + * Native quantum computing integration for CodeQ. + * Provides in-process tools for device management, job submission, + * cost estimation, and result retrieval via the qBraid API. + * + * This replaces the pod_mcp MCP server for core quantum workflows, + * giving CodeQ tight integration with auth, permissions, and telemetry. + */ + +export { QUANTUM_TOOLS } from "./tools" +export * as QuantumClient from "./client" diff --git a/packages/opencode/src/quantum/tools.ts b/packages/opencode/src/quantum/tools.ts new file mode 100644 index 00000000000..9b4076323f1 --- /dev/null +++ b/packages/opencode/src/quantum/tools.ts @@ -0,0 +1,290 @@ +/** + * Quantum Tools + * + * Native CodeQ tool definitions for quantum computing operations. + * These replace the pod_mcp MCP server for core quantum workflows, + * running in-process with access to CodeQ's auth, permissions, and telemetry. + */ + +import z from "zod" +import { Tool } from "../tool/tool" +import * as client from "./client" + +// ============================================================================ +// quantum_devices — List available quantum devices +// ============================================================================ + +export const QuantumDevicesTool = Tool.define("quantum_devices", { + description: [ + "List available quantum computing devices (QPUs and simulators) from qBraid.", + "Returns device ID, name, vendor, status, qubit count, and pricing.", + "Use the status and provider filters to narrow results.", + "Device IDs from this list are needed for job submission and cost estimation.", + ].join("\n"), + parameters: z.object({ + status: z.enum(["online", "offline", "all"]).optional() + .describe("Filter devices by status. Defaults to all."), + provider: z.string().optional() + .describe("Filter by provider (e.g., 'ibm', 'aws', 'ionq', 'rigetti')."), + }), + async execute(params) { + const devices = await client.listDevices({ + status: params.status === "all" ? undefined : params.status, + provider: params.provider, + }) + + if (devices.length === 0) { + return { + title: "No devices found", + metadata: { count: 0 }, + output: "No quantum devices match the given filters.", + } + } + + const lines = devices.map((d) => { + const pricing = d.pricing + ? `$${d.pricing.perShot ?? 0}/shot + $${d.pricing.perTask ?? 0}/task` + : "N/A" + return `${d.id} | ${d.name} | ${d.vendor} | ${d.status} | ${d.qubits}q | ${d.paradigm} | ${pricing}` + }) + + const header = "ID | Name | Vendor | Status | Qubits | Paradigm | Pricing" + const separator = "-".repeat(80) + const output = [header, separator, ...lines].join("\n") + + return { + title: `${devices.length} quantum devices`, + metadata: { count: devices.length }, + output, + } + }, +}) + +// ============================================================================ +// quantum_estimate_cost — Estimate cost before submitting a job +// ============================================================================ + +export const QuantumEstimateCostTool = Tool.define("quantum_estimate_cost", { + description: [ + "Estimate the cost in qBraid credits for running a quantum job on a specific device.", + "Use this BEFORE submitting a job to check the cost and user's credit balance.", + "Returns the estimated cost breakdown and the user's current credit balance.", + ].join("\n"), + parameters: z.object({ + device_id: z.string().describe("The quantum device ID to estimate cost for."), + shots: z.number().int().min(1).default(1024) + .describe("Number of measurement shots."), + }), + async execute(params) { + const [estimate, credits] = await Promise.all([ + client.estimateCost(params.device_id, params.shots), + client.getCredits().catch(() => ({ balance: -1 })), + ]) + + const balanceStr = credits.balance >= 0 ? `${credits.balance}` : "unknown" + const sufficient = credits.balance >= 0 + ? (credits.balance >= estimate.estimatedCredits ? "Yes" : "NO — insufficient credits") + : "unknown" + + const output = [ + `Device: ${params.device_id}`, + `Shots: ${params.shots}`, + `Estimated cost: ${estimate.estimatedCredits.toFixed(4)} credits`, + ` Per-shot: ${estimate.breakdown.perShot.toFixed(4)}`, + ` Per-task: ${estimate.breakdown.perTask.toFixed(4)}`, + `Current balance: ${balanceStr} credits`, + `Sufficient funds: ${sufficient}`, + ].join("\n") + + return { + title: `Cost estimate: ${estimate.estimatedCredits.toFixed(4)} credits`, + metadata: { cost: estimate.estimatedCredits, balance: credits.balance }, + output, + } + }, +}) + +// ============================================================================ +// quantum_submit_job — Submit a QASM circuit with cost approval +// ============================================================================ + +export const QuantumSubmitJobTool = Tool.define("quantum_submit_job", { + description: [ + "Submit a quantum circuit (OpenQASM format) to a device for execution.", + "IMPORTANT: This tool uses the native permission system to get user approval", + "for the estimated cost before submitting. The user will see a cost estimate", + "and must explicitly approve the submission.", + "Use quantum_estimate_cost first to check costs, then call this to submit.", + ].join("\n"), + parameters: z.object({ + device_id: z.string().describe("The quantum device ID to run on."), + qasm: z.string().describe("The OpenQASM 2.0 or 3.0 circuit code."), + shots: z.number().int().min(1).default(1024).describe("Number of measurement shots."), + }), + async execute(params, ctx) { + // Estimate cost first + const estimate = await client.estimateCost(params.device_id, params.shots) + + // Use CodeQ's native permission system for cost approval + await ctx.ask({ + permission: "quantum_submit", + patterns: [params.device_id], + always: [], + metadata: { + device: params.device_id, + shots: params.shots, + cost: estimate.estimatedCredits, + summary: `Submit quantum job to ${params.device_id} (${params.shots} shots, ~${estimate.estimatedCredits.toFixed(4)} credits)`, + }, + }) + + const job = await client.submitJob({ + deviceId: params.device_id, + qasm: params.qasm, + shots: params.shots, + }) + + return { + title: `Job submitted: ${job.id}`, + metadata: { jobId: job.id, device: params.device_id }, + output: [ + `Job ID: ${job.id}`, + `Device: ${job.device}`, + `Status: ${job.status}`, + `Shots: ${job.shots}`, + `Created: ${job.createdAt}`, + ``, + `Use quantum_get_result with this job ID to retrieve results when complete.`, + ].join("\n"), + } + }, +}) + +// ============================================================================ +// quantum_get_result — Retrieve results from a submitted job +// ============================================================================ + +export const QuantumGetResultTool = Tool.define("quantum_get_result", { + description: [ + "Retrieve the measurement results from a previously submitted quantum job.", + "If the job is still running, returns the current status.", + "Measurement results are returned as a dictionary of bitstring counts.", + ].join("\n"), + parameters: z.object({ + job_id: z.string().describe("The quantum job ID to retrieve results for."), + }), + async execute(params) { + const job = await client.getJob(params.job_id) + + if (job.status !== "COMPLETED" && job.status !== "completed") { + return { + title: `Job ${params.job_id}: ${job.status}`, + metadata: { jobId: params.job_id, status: job.status }, + output: [ + `Job ID: ${params.job_id}`, + `Status: ${job.status}`, + `Device: ${job.device}`, + job.status === "QUEUED" || job.status === "RUNNING" + ? "The job is still processing. Try again in a moment." + : `The job ended with status: ${job.status}`, + ].join("\n"), + } + } + + const result = await client.getResult(params.job_id) + + const measurements = result.measurements + ? Object.entries(result.measurements) + .sort(([, a], [, b]) => b - a) + .map(([state, count]) => ` ${state}: ${count}`) + .join("\n") + : "No measurement data available" + + return { + title: `Results: ${params.job_id}`, + metadata: { jobId: params.job_id, status: job.status }, + output: [ + `Job ID: ${params.job_id}`, + `Status: ${job.status}`, + `Device: ${job.device}`, + `Cost: ${job.cost ?? "N/A"} credits`, + ``, + `Measurement results:`, + measurements, + ].join("\n"), + } + }, +}) + +// ============================================================================ +// quantum_cancel_job — Cancel a running or queued job +// ============================================================================ + +export const QuantumCancelJobTool = Tool.define("quantum_cancel_job", { + description: "Cancel a queued or running quantum job. Returns whether the cancellation succeeded.", + parameters: z.object({ + job_id: z.string().describe("The quantum job ID to cancel."), + }), + async execute(params) { + const result = await client.cancelJob(params.job_id) + + return { + title: result.success ? `Cancelled: ${params.job_id}` : `Cancel failed: ${params.job_id}`, + metadata: { success: result.success }, + output: result.success + ? `Successfully cancelled job ${params.job_id}.` + : `Failed to cancel job ${params.job_id}. It may have already completed or been cancelled.`, + } + }, +}) + +// ============================================================================ +// quantum_list_jobs — List recent quantum jobs +// ============================================================================ + +export const QuantumListJobsTool = Tool.define("quantum_list_jobs", { + description: "List recent quantum jobs with optional status filter. Shows job IDs, devices, status, and costs.", + parameters: z.object({ + status: z.string().optional().describe("Filter by job status (e.g., 'COMPLETED', 'RUNNING', 'QUEUED', 'FAILED')."), + limit: z.number().int().min(1).max(100).default(10).describe("Maximum number of jobs to return."), + }), + async execute(params) { + const jobs = await client.listJobs({ + status: params.status, + limit: params.limit, + }) + + if (jobs.length === 0) { + return { + title: "No jobs found", + metadata: { count: 0 }, + output: "No quantum jobs match the given filters.", + } + } + + const lines = jobs.map((j) => + `${j.id} | ${j.device} | ${j.status} | ${j.shots} shots | ${j.cost ?? "N/A"} credits | ${j.createdAt}`, + ) + + const header = "ID | Device | Status | Shots | Cost | Created" + const output = [header, "-".repeat(80), ...lines].join("\n") + + return { + title: `${jobs.length} quantum jobs`, + metadata: { count: jobs.length }, + output, + } + }, +}) + +/** + * All quantum tools for registration in the tool registry. + */ +export const QUANTUM_TOOLS = [ + QuantumDevicesTool, + QuantumEstimateCostTool, + QuantumSubmitJobTool, + QuantumGetResultTool, + QuantumCancelJobTool, + QuantumListJobsTool, +] diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5ed5a879b48..6ccf8b84ffc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -27,6 +27,7 @@ import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" import { ApplyPatchTool } from "./apply_patch" +import { QUANTUM_TOOLS } from "../quantum" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -115,6 +116,7 @@ export namespace ToolRegistry { ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), + ...QUANTUM_TOOLS, ...custom, ] } From e2fdffd9ee18a45c9a8b35be4858e4ceec27c48f Mon Sep 17 00:00:00 2001 From: Kenny-Heitritter Date: Sat, 21 Feb 2026 18:32:06 -0600 Subject: [PATCH 13/13] fix: address review findings across telemetry, consent, branding, and quantum Telemetry (integration.ts, collector.ts, index.ts): - Remove process.once SIGTERM/beforeExit handlers that crash outside Instance context; rely on Instance.state disposal for flush - Move recordedUserMessages to TelemetryState (cleared per session) to prevent unbounded Set growth across sessions - Remove dead assistantToUser map and dead PartUpdated text branch - Use info.parentID for turn start time lookup instead of fragile insertion-order heuristic on turnStartTimes map - Fix exported finalizeTurn() to actually call collector.finalizeTurn() - Cache consentTier/dataLevel from initialize() to avoid redundant getConsentStatus() call in startSession() - Remove duplicate retry/recordRetry export from Telemetry namespace Consent dialog (dialog-telemetry-consent.tsx, app.tsx): - Use DialogTelemetryConsent.show() API instead of raw dialog.replace() to properly wire onResult and onClose callbacks - Default tier to 'paid' (genuine opt-out) until actual tier detection is implemented; 'free' forced all users into required telemetry - Add double-fire guard on handleSelect to prevent duplicate KV writes - Fix Esc handler: free tier forces accept, paid tier declines - Fix unsafe 'as boolean' cast on KV value with === true check - Add consentShown guard to prevent re-show on rebootstrap cycles - Remove unnecessary spread in component Branding (apply.ts, schema.ts, brand.json): - Rewrite non-exclusive models.ts get() replacement to use actual upstream scope variables (filepath, Bun.file, data macro, refresh) instead of nonexistent readCache/writeCache/url - Add match-failure assertions to all regex transforms so upstream refactors produce loud build errors instead of silent failures - Remove unused 'default' field from ModelsSchema and brand.json Quantum (client.ts, tools.ts): - Add Zod schemas for QuantumDevice, QuantumJob, JobResult with runtime .parse() validation on all API responses - Add pricingAvailable flag to CostEstimate; warn when pricing is unavailable instead of silently returning 0 - Add ctx.ask() permission gate to quantum_cancel_job (destructive op) - Thread ctx.abort signal through all tool execute -> client calls - Add 30s default timeout via AbortSignal.timeout on all fetch calls - Cache resolveAuth() result for 5s to avoid repeated disk reads - Truncate error response bodies to 500 chars - Normalize job status with .toUpperCase() for consistent comparison - Wrap getResult() in try/catch for TOCTOU race after status check - Fix pricing display from '$' prefix to 'credits' suffix --- branding/apply.ts | 57 ++++-- branding/qbraid/brand.json | 1 - branding/schema.ts | 5 +- packages/opencode/src/cli/cmd/tui/app.tsx | 15 +- .../component/dialog-telemetry-consent.tsx | 9 +- packages/opencode/src/quantum/client.ts | 178 +++++++++++------- packages/opencode/src/quantum/tools.ts | 78 +++++--- packages/opencode/src/telemetry/collector.ts | 9 +- packages/opencode/src/telemetry/index.ts | 6 - .../opencode/src/telemetry/integration.ts | 57 +++--- 10 files changed, 253 insertions(+), 162 deletions(-) diff --git a/branding/apply.ts b/branding/apply.ts index 9c4b6bc00f1..ac4616026b3 100644 --- a/branding/apply.ts +++ b/branding/apply.ts @@ -363,50 +363,63 @@ const PURPLE = RGBA.fromHex("#9370DB")`, if (config.models.exclusive) { // Exclusive mode: only branded models, no models.dev fetch - return content.replace( + const result = content.replace( /export async function get\(\) \{[\s\S]*?\n \}/, `export async function get() { // Branding: embedded models (exclusive mode, no external fetch) return ${JSON.stringify(modelsJson)} as Record }`, ) + if (result === content) { + throw new Error("models.ts branding transform failed: get() regex did not match (exclusive mode)") + } + return result } // Default mode: prepend branded models, then merge models.dev data // This ensures qBraid models appear first and are the defaults, // while all upstream providers (Anthropic, OpenAI, Copilot, Codex, etc.) // remain available. + // + // The replacement body references variables from the original models.ts scope: + // - filepath (const, path to cache file) + // - data (Bun macro import for bundled snapshot) + // - refresh() (background fetch to update cache) const brandedModelsStr = JSON.stringify(modelsJson) - return content.replace( + const result = content.replace( /export async function get\(\) \{[\s\S]*?\n \}/, `export async function get() { // Branding: qBraid models prepended as defaults const branded = ${brandedModelsStr} as Record - // Fetch upstream models from models.dev (or cache) + // Kick off background cache refresh + refresh() + + // Try cached models first, then macro bundle, then live fetch let upstream: Record = {} try { - const cached = await readCache() + const file = Bun.file(filepath) + const cached = await file.json().catch(() => undefined) if (cached) { - upstream = cached + upstream = cached as Record + } else if (typeof data === "function") { + upstream = JSON.parse(await data()) as Record } else { - const response = await fetch(url) - if (response.ok) { - upstream = await response.json() as Record - await writeCache(upstream) - } + const json = await fetch("https://models.dev/api.json").then((x) => x.text()) + upstream = JSON.parse(json) as Record } - } catch (e) { - // Fall back to bundled snapshot if fetch fails - try { - upstream = (await import("./models-snapshot")).default as Record - } catch {} + } catch { + // All upstream sources failed — branded models only } - // Merge: branded providers first, then upstream (branded wins on conflict) + // Merge: branded providers win on conflict return { ...upstream, ...branded } }`, ) + if (result === content) { + throw new Error("models.ts branding transform failed: get() regex did not match. Has the upstream function signature changed?") + } + return result }, }, @@ -431,10 +444,14 @@ const PURPLE = RGBA.fromHex("#9370DB")`, transform: (content, config) => { if (!config.models?.exclusive) return content - return content.replace( + const result = content.replace( /const BUILTIN = \["[^"]*"(?:,\s*"[^"]*")*\]/, "const BUILTIN: string[] = [] // Cleared by branding - no external plugins", ) + if (result === content) { + throw new Error("plugin/index.ts branding transform failed: BUILTIN regex did not match") + } + return result }, }, @@ -446,12 +463,16 @@ const PURPLE = RGBA.fromHex("#9370DB")`, transform: (content, config) => { if (!config.models?.exclusive) return content - return content.replace( + const result = content.replace( /const CUSTOM_LOADERS: Record = \{[\s\S]*?\n \}(?=\n\n export const Model)/, `const CUSTOM_LOADERS: Record = { // All custom loaders removed by branding (exclusive mode) }`, ) + if (result === content) { + throw new Error("provider.ts branding transform failed: CUSTOM_LOADERS regex did not match") + } + return result }, }, diff --git a/branding/qbraid/brand.json b/branding/qbraid/brand.json index 33f73edabc9..31745d1a96a 100644 --- a/branding/qbraid/brand.json +++ b/branding/qbraid/brand.json @@ -29,7 +29,6 @@ } }, "models": { - "default": true, "exclusive": false, "removeProviders": ["opencode"], "source": "./models.json" diff --git a/branding/schema.ts b/branding/schema.ts index 91548c91683..47dcd6f5842 100644 --- a/branding/schema.ts +++ b/branding/schema.ts @@ -45,10 +45,9 @@ export const ModelsSchema = z.object({ .optional(), /** Provider IDs to completely remove */ removeProviders: z.array(z.string()).optional(), - /** If true, only use the providers defined in this config (locks out all others) */ + /** If true, only use the providers defined in this config (locks out all others). + * When false/unset, branded models are prepended as defaults but upstream providers remain available. */ exclusive: z.boolean().optional(), - /** If true, branded models are prepended as defaults but upstream providers remain available */ - default: z.boolean().optional(), }) /** diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index de9bad85220..f3fc7392b8f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -277,26 +277,25 @@ function App() { // --- First-run telemetry consent dialog --- // Fires once when sync is complete and user hasn't seen the consent dialog yet. // Must fire *before* the provider connect dialog so consent is captured first. + let consentShown = false createEffect( on( () => sync.status === "complete" && kv.get(KV_TELEMETRY_CONSENT_SHOWN) === undefined, (needsConsent, prev) => { - if (!needsConsent || prev) return + if (!needsConsent || prev || consentShown) return + consentShown = true // Load any existing consent value into the telemetry module const existing = kv.get(KV_TELEMETRY_ENABLED) if (existing !== undefined) { - Telemetry.loadConsent(existing as boolean) - // Already consented in a previous session, skip dialog + Telemetry.loadConsent(existing === true) kv.set(KV_TELEMETRY_CONSENT_SHOWN, true) return } - // Show the consent dialog. Free tier = informational only. - // TODO: Determine actual tier from auth/consent service. Default to "free". - dialog.replace(() => ( - {}} /> - )) + // Default to "paid" tier (gives genuine opt-out) until we can + // determine the actual tier from the auth/consent service. + DialogTelemetryConsent.show(dialog, "paid") }, ), ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx index b28cf95576b..7f585a05e3b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx @@ -63,7 +63,10 @@ export function DialogTelemetryConsent(props: DialogTelemetryConsentProps) { decline: "No Thanks", } + let handled = false const handleSelect = (key: string) => { + if (handled) return + handled = true const accepted = key === "understand" || key === "enable" kv.set(KV_TELEMETRY_CONSENT_SHOWN, true) kv.set(KV_TELEMETRY_ENABLED, accepted) @@ -97,7 +100,7 @@ export function DialogTelemetryConsent(props: DialogTelemetryConsentProps) { {message()} - + {(key) => ( resolve(accepted)} /> ), - // If user presses Esc on paid tier, treat as decline - () => resolve(false), + // Esc handler: free tier = accept (required), paid tier = decline + () => resolve(tier === "free"), ) }) } diff --git a/packages/opencode/src/quantum/client.ts b/packages/opencode/src/quantum/client.ts index 4866536e4b2..2be977dcb4b 100644 --- a/packages/opencode/src/quantum/client.ts +++ b/packages/opencode/src/quantum/client.ts @@ -8,6 +8,7 @@ import { Log } from "../util/log" import { Auth } from "../auth" +import z from "zod" import path from "path" import os from "os" import fs from "fs/promises" @@ -15,55 +16,72 @@ import fs from "fs/promises" const log = Log.create({ service: "quantum:client" }) const DEFAULT_API_URL = "https://api-v2.qbraid.com/api/v1" +const MAX_ERROR_BODY = 500 -export interface QuantumDevice { - id: string - name: string - vendor: string - provider: string - type: string - status: string - qubits: number - paradigm: string - pricing?: { - perShot?: number - perTask?: number - perMinute?: number - } -} +// --- Zod schemas for API response validation --- -export interface QuantumJob { - id: string - device: string - status: string - shots: number - createdAt: string - endedAt?: string - cost?: number -} +const QuantumDeviceSchema = z.object({ + id: z.string(), + name: z.string(), + vendor: z.string(), + provider: z.string(), + type: z.string().default("unknown"), + status: z.string(), + qubits: z.number().default(0), + paradigm: z.string().default("unknown"), + pricing: z.object({ + perShot: z.number().optional(), + perTask: z.number().optional(), + perMinute: z.number().optional(), + }).optional(), +}) -export interface JobResult { - jobId: string - status: string - measurements?: Record - success: boolean -} +const QuantumJobSchema = z.object({ + id: z.string(), + device: z.string(), + status: z.string(), + shots: z.number(), + createdAt: z.string(), + endedAt: z.string().optional(), + cost: z.number().optional(), +}) + +const JobResultSchema = z.object({ + jobId: z.string().optional(), + status: z.string().optional(), + measurements: z.record(z.string(), z.number()).optional(), + success: z.boolean().optional(), +}) + +export type QuantumDevice = z.infer +export type QuantumJob = z.infer +export type JobResult = z.infer export interface CostEstimate { deviceId: string shots: number estimatedCredits: number + pricingAvailable: boolean breakdown: { perShot: number perTask: number } } +// --- Auth resolution with short-lived cache --- + +let cachedAuth: { apiKey: string; baseUrl: string; expiry: number } | null = null + /** * Resolve the qBraid API key and base URL. * Priority: env var > config provider > ~/.qbraid/qbraidrc + * Cached for 5 seconds to avoid repeated disk reads within a single tool call. */ async function resolveAuth(): Promise<{ apiKey: string; baseUrl: string } | null> { + if (cachedAuth && Date.now() < cachedAuth.expiry) { + return { apiKey: cachedAuth.apiKey, baseUrl: cachedAuth.baseUrl } + } + let apiKey: string | undefined let baseUrl = DEFAULT_API_URL @@ -100,7 +118,10 @@ async function resolveAuth(): Promise<{ apiKey: string; baseUrl: string } | null const content = await fs.readFile(rcPath, "utf-8") for (const line of content.split("\n")) { const keyMatch = line.trim().match(/^api-key\s*=\s*(.+)/) - if (keyMatch) apiKey = keyMatch[1].trim() + if (keyMatch) { + apiKey = keyMatch[1].trim() + break + } const urlMatch = line.trim().match(/^url\s*=\s*(.+)/) if (urlMatch) baseUrl = urlMatch[1].trim() } @@ -114,10 +135,19 @@ async function resolveAuth(): Promise<{ apiKey: string; baseUrl: string } | null } if (!apiKey) return null + + cachedAuth = { apiKey, baseUrl, expiry: Date.now() + 5_000 } return { apiKey, baseUrl } } -async function request(method: string, endpoint: string, body?: unknown): Promise { +// --- HTTP request helper --- + +async function request( + method: string, + endpoint: string, + body?: unknown, + signal?: AbortSignal, +): Promise { const auth = await resolveAuth() if (!auth) throw new Error("No qBraid API key found. Run `codeq /connect` to set up qBraid.") @@ -131,45 +161,57 @@ async function request(method: string, endpoint: string, body?: unknown): Pro "api-key": auth.apiKey, }, body: body ? JSON.stringify(body) : undefined, + signal: signal ?? AbortSignal.timeout(30_000), }) if (!response.ok) { - const text = await response.text().catch(() => "") + const text = (await response.text().catch(() => "")).slice(0, MAX_ERROR_BODY) throw new Error(`qBraid API ${method} ${endpoint} failed (${response.status}): ${text}`) } return response.json() as Promise } +// --- API functions --- + /** * List available quantum devices with optional filters. */ -export async function listDevices(filters?: { - status?: string - provider?: string -}): Promise { +export async function listDevices( + filters?: { status?: string; provider?: string }, + signal?: AbortSignal, +): Promise { const params = new URLSearchParams() if (filters?.status) params.set("status", filters.status) if (filters?.provider) params.set("provider", filters.provider) const query = params.toString() const endpoint = `/quantum/devices${query ? `?${query}` : ""}` - const data = await request("GET", endpoint) - return Array.isArray(data) ? data : data.devices ?? [] + const data = await request("GET", endpoint, undefined, signal) + + const arr = Array.isArray(data) + ? data + : (data as { devices?: unknown[] }).devices ?? [] + + return arr.map((d: unknown) => QuantumDeviceSchema.parse(d)) } /** * Get details for a specific device. */ -export async function getDevice(deviceId: string): Promise { - return request("GET", `/quantum/devices/${encodeURIComponent(deviceId)}`) +export async function getDevice(deviceId: string, signal?: AbortSignal): Promise { + const data = await request("GET", `/quantum/devices/${encodeURIComponent(deviceId)}`, undefined, signal) + return QuantumDeviceSchema.parse(data) } /** * Estimate the cost of running a job on a device. + * NOTE: This is a client-side estimate based on device pricing metadata. + * If pricing is unavailable the estimate is 0 — check `pricingAvailable`. */ -export async function estimateCost(deviceId: string, shots: number): Promise { - const device = await getDevice(deviceId) +export async function estimateCost(deviceId: string, shots: number, signal?: AbortSignal): Promise { + const device = await getDevice(deviceId, signal) + const pricingAvailable = device.pricing != null const perShot = device.pricing?.perShot ?? 0 const perTask = device.pricing?.perTask ?? 0 const estimatedCredits = perShot * shots + perTask @@ -178,6 +220,7 @@ export async function estimateCost(deviceId: string, shots: number): Promise { - return request("POST", "/quantum/jobs", { +export async function submitJob( + params: { deviceId: string; qasm: string; shots: number }, + signal?: AbortSignal, +): Promise { + const data = await request("POST", "/quantum/jobs", { device: params.deviceId, openQasm: params.qasm, shots: params.shots, - }) + }, signal) + return QuantumJobSchema.parse(data) } /** * Get the status and metadata of a job. */ -export async function getJob(jobId: string): Promise { - return request("GET", `/quantum/jobs/${encodeURIComponent(jobId)}`) +export async function getJob(jobId: string, signal?: AbortSignal): Promise { + const data = await request("GET", `/quantum/jobs/${encodeURIComponent(jobId)}`, undefined, signal) + return QuantumJobSchema.parse(data) } /** * Get the results of a completed job. */ -export async function getResult(jobId: string): Promise { - return request("GET", `/quantum/jobs/${encodeURIComponent(jobId)}/result`) +export async function getResult(jobId: string, signal?: AbortSignal): Promise { + const data = await request("GET", `/quantum/jobs/${encodeURIComponent(jobId)}/result`, undefined, signal) + return JobResultSchema.parse(data) } /** * Cancel a running or queued job. */ -export async function cancelJob(jobId: string): Promise<{ success: boolean }> { - return request<{ success: boolean }>("POST", `/quantum/jobs/${encodeURIComponent(jobId)}/cancel`) +export async function cancelJob(jobId: string, signal?: AbortSignal): Promise<{ success: boolean }> { + return request<{ success: boolean }>("POST", `/quantum/jobs/${encodeURIComponent(jobId)}/cancel`, undefined, signal) } /** * List recent jobs with optional filters. */ -export async function listJobs(filters?: { - status?: string - limit?: number -}): Promise { +export async function listJobs( + filters?: { status?: string; limit?: number }, + signal?: AbortSignal, +): Promise { const params = new URLSearchParams() if (filters?.status) params.set("status", filters.status) if (filters?.limit) params.set("limit", String(filters.limit)) const query = params.toString() const endpoint = `/quantum/jobs${query ? `?${query}` : ""}` - const data = await request("GET", endpoint) - return Array.isArray(data) ? data : data.jobs ?? [] + const data = await request("GET", endpoint, undefined, signal) + + const arr = Array.isArray(data) + ? data + : (data as { jobs?: unknown[] }).jobs ?? [] + + return arr.map((j: unknown) => QuantumJobSchema.parse(j)) } /** * Get account credit balance. */ -export async function getCredits(): Promise<{ balance: number }> { - return request<{ balance: number }>("GET", "/user/credits") +export async function getCredits(signal?: AbortSignal): Promise<{ balance: number }> { + return request<{ balance: number }>("GET", "/user/credits", undefined, signal) } /** diff --git a/packages/opencode/src/quantum/tools.ts b/packages/opencode/src/quantum/tools.ts index 9b4076323f1..42894e7db96 100644 --- a/packages/opencode/src/quantum/tools.ts +++ b/packages/opencode/src/quantum/tools.ts @@ -27,11 +27,11 @@ export const QuantumDevicesTool = Tool.define("quantum_devices", { provider: z.string().optional() .describe("Filter by provider (e.g., 'ibm', 'aws', 'ionq', 'rigetti')."), }), - async execute(params) { + async execute(params, ctx) { const devices = await client.listDevices({ status: params.status === "all" ? undefined : params.status, provider: params.provider, - }) + }, ctx.abort) if (devices.length === 0) { return { @@ -43,7 +43,7 @@ export const QuantumDevicesTool = Tool.define("quantum_devices", { const lines = devices.map((d) => { const pricing = d.pricing - ? `$${d.pricing.perShot ?? 0}/shot + $${d.pricing.perTask ?? 0}/task` + ? `${d.pricing.perShot ?? 0}/shot + ${d.pricing.perTask ?? 0}/task credits` : "N/A" return `${d.id} | ${d.name} | ${d.vendor} | ${d.status} | ${d.qubits}q | ${d.paradigm} | ${pricing}` }) @@ -75,10 +75,10 @@ export const QuantumEstimateCostTool = Tool.define("quantum_estimate_cost", { shots: z.number().int().min(1).default(1024) .describe("Number of measurement shots."), }), - async execute(params) { + async execute(params, ctx) { const [estimate, credits] = await Promise.all([ - client.estimateCost(params.device_id, params.shots), - client.getCredits().catch(() => ({ balance: -1 })), + client.estimateCost(params.device_id, params.shots, ctx.abort), + client.getCredits(ctx.abort).catch(() => ({ balance: -1 })), ]) const balanceStr = credits.balance >= 0 ? `${credits.balance}` : "unknown" @@ -86,6 +86,10 @@ export const QuantumEstimateCostTool = Tool.define("quantum_estimate_cost", { ? (credits.balance >= estimate.estimatedCredits ? "Yes" : "NO — insufficient credits") : "unknown" + const pricingNote = estimate.pricingAvailable + ? "" + : "\nWARNING: Pricing data unavailable for this device. Actual cost may differ." + const output = [ `Device: ${params.device_id}`, `Shots: ${params.shots}`, @@ -94,11 +98,12 @@ export const QuantumEstimateCostTool = Tool.define("quantum_estimate_cost", { ` Per-task: ${estimate.breakdown.perTask.toFixed(4)}`, `Current balance: ${balanceStr} credits`, `Sufficient funds: ${sufficient}`, - ].join("\n") + pricingNote, + ].filter(Boolean).join("\n") return { title: `Cost estimate: ${estimate.estimatedCredits.toFixed(4)} credits`, - metadata: { cost: estimate.estimatedCredits, balance: credits.balance }, + metadata: { cost: estimate.estimatedCredits, balance: credits.balance, pricingAvailable: estimate.pricingAvailable }, output, } }, @@ -123,7 +128,11 @@ export const QuantumSubmitJobTool = Tool.define("quantum_submit_job", { }), async execute(params, ctx) { // Estimate cost first - const estimate = await client.estimateCost(params.device_id, params.shots) + const estimate = await client.estimateCost(params.device_id, params.shots, ctx.abort) + + const costNote = estimate.pricingAvailable + ? `~${estimate.estimatedCredits.toFixed(4)} credits` + : "unknown (pricing unavailable)" // Use CodeQ's native permission system for cost approval await ctx.ask({ @@ -134,7 +143,8 @@ export const QuantumSubmitJobTool = Tool.define("quantum_submit_job", { device: params.device_id, shots: params.shots, cost: estimate.estimatedCredits, - summary: `Submit quantum job to ${params.device_id} (${params.shots} shots, ~${estimate.estimatedCredits.toFixed(4)} credits)`, + pricingAvailable: estimate.pricingAvailable, + summary: `Submit quantum job to ${params.device_id} (${params.shots} shots, ${costNote})`, }, }) @@ -142,7 +152,7 @@ export const QuantumSubmitJobTool = Tool.define("quantum_submit_job", { deviceId: params.device_id, qasm: params.qasm, shots: params.shots, - }) + }, ctx.abort) return { title: `Job submitted: ${job.id}`, @@ -173,10 +183,11 @@ export const QuantumGetResultTool = Tool.define("quantum_get_result", { parameters: z.object({ job_id: z.string().describe("The quantum job ID to retrieve results for."), }), - async execute(params) { - const job = await client.getJob(params.job_id) + async execute(params, ctx) { + const job = await client.getJob(params.job_id, ctx.abort) + const status = job.status.toUpperCase() - if (job.status !== "COMPLETED" && job.status !== "completed") { + if (status !== "COMPLETED") { return { title: `Job ${params.job_id}: ${job.status}`, metadata: { jobId: params.job_id, status: job.status }, @@ -184,14 +195,28 @@ export const QuantumGetResultTool = Tool.define("quantum_get_result", { `Job ID: ${params.job_id}`, `Status: ${job.status}`, `Device: ${job.device}`, - job.status === "QUEUED" || job.status === "RUNNING" + status === "QUEUED" || status === "RUNNING" ? "The job is still processing. Try again in a moment." : `The job ended with status: ${job.status}`, ].join("\n"), } } - const result = await client.getResult(params.job_id) + let result: client.JobResult + try { + result = await client.getResult(params.job_id, ctx.abort) + } catch (error) { + return { + title: `Job ${params.job_id}: completed (results unavailable)`, + metadata: { jobId: params.job_id, status: job.status }, + output: [ + `Job ID: ${params.job_id}`, + `Status: ${job.status}`, + `Device: ${job.device}`, + `Error retrieving results: ${error instanceof Error ? error.message : String(error)}`, + ].join("\n"), + } + } const measurements = result.measurements ? Object.entries(result.measurements) @@ -221,12 +246,23 @@ export const QuantumGetResultTool = Tool.define("quantum_get_result", { // ============================================================================ export const QuantumCancelJobTool = Tool.define("quantum_cancel_job", { - description: "Cancel a queued or running quantum job. Returns whether the cancellation succeeded.", + description: "Cancel a queued or running quantum job. Requires user confirmation. Returns whether the cancellation succeeded.", parameters: z.object({ job_id: z.string().describe("The quantum job ID to cancel."), }), - async execute(params) { - const result = await client.cancelJob(params.job_id) + async execute(params, ctx) { + // Cancellation is destructive — require user approval + await ctx.ask({ + permission: "quantum_cancel", + patterns: [params.job_id], + always: [], + metadata: { + jobId: params.job_id, + summary: `Cancel quantum job ${params.job_id}`, + }, + }) + + const result = await client.cancelJob(params.job_id, ctx.abort) return { title: result.success ? `Cancelled: ${params.job_id}` : `Cancel failed: ${params.job_id}`, @@ -248,11 +284,11 @@ export const QuantumListJobsTool = Tool.define("quantum_list_jobs", { status: z.string().optional().describe("Filter by job status (e.g., 'COMPLETED', 'RUNNING', 'QUEUED', 'FAILED')."), limit: z.number().int().min(1).max(100).default(10).describe("Maximum number of jobs to return."), }), - async execute(params) { + async execute(params, ctx) { const jobs = await client.listJobs({ status: params.status, limit: params.limit, - }) + }, ctx.abort) if (jobs.length === 0) { return { diff --git a/packages/opencode/src/telemetry/collector.ts b/packages/opencode/src/telemetry/collector.ts index 54a30d8b6e4..d374b480838 100644 --- a/packages/opencode/src/telemetry/collector.ts +++ b/packages/opencode/src/telemetry/collector.ts @@ -20,6 +20,7 @@ import type { TelemetrySession, TelemetryTurn, ToolCallData, + UserTier, } from "./types" const log = Log.create({ service: "telemetry:collector" }) @@ -54,6 +55,7 @@ export class TelemetryCollector { private isEnabled = false private authToken: string | null = null private dataLevel: "full" | "metrics-only" = "full" + private consentTier: UserTier = "free" constructor() { this.signalTracker = createSignalTracker() @@ -72,6 +74,7 @@ export class TelemetryCollector { } this.dataLevel = consent.dataLevel + this.consentTier = consent.tier const config = await Config.get() const telemetryConfig = config.qbraid?.telemetry @@ -100,8 +103,6 @@ export class TelemetryCollector { async startSession(sessionId: string, userId: string, organizationId: string): Promise { if (!this.isEnabled) return - const consent = await getConsentStatus(this.authToken ?? undefined) - this.sessionState = { sessionId, startedAt: new Date(), @@ -136,8 +137,8 @@ export class TelemetryCollector { environment: this.sessionState.environment, startedAt: this.sessionState.startedAt.toISOString(), durationSeconds: 0, - consentTier: consent.tier, - dataLevel: consent.dataLevel, + consentTier: this.consentTier, + dataLevel: this.dataLevel, metrics: this.sessionState.metrics, signals: this.signalTracker.getSignals(false), modelUsage: {}, diff --git a/packages/opencode/src/telemetry/index.ts b/packages/opencode/src/telemetry/index.ts index ac1d254ae2c..ed13fe07aa5 100644 --- a/packages/opencode/src/telemetry/index.ts +++ b/packages/opencode/src/telemetry/index.ts @@ -41,7 +41,6 @@ import { shutdownTelemetryIntegration, finalizeTurn, recordUserTurn, - recordRetry, } from "./integration" import type { ConsentStatus, TelemetrySession, TelemetryTurn } from "./types" @@ -105,11 +104,6 @@ export namespace Telemetry { */ export const userMessage = recordUserTurn - /** - * Record that a turn was retried - */ - export const retry = recordRetry - /** * Start collecting for a new session * diff --git a/packages/opencode/src/telemetry/integration.ts b/packages/opencode/src/telemetry/integration.ts index 73279f357d2..fc9edb885c1 100644 --- a/packages/opencode/src/telemetry/integration.ts +++ b/packages/opencode/src/telemetry/integration.ts @@ -27,8 +27,8 @@ interface TelemetryState { activeSessions: Map /** Maps user messageID -> timestamp for latency calculation */ turnStartTimes: Map - /** Maps assistant messageID -> parent user messageID */ - assistantToUser: Map + /** Tracks which user messages have been recorded (cleared per session) */ + recordedUserMessages: Set unsubscribers: (() => void)[] initialized: boolean } @@ -37,7 +37,7 @@ const getTelemetryState = Instance.state( () => ({ activeSessions: new Map(), turnStartTimes: new Map(), - assistantToUser: new Map(), + recordedUserMessages: new Set(), unsubscribers: [], initialized: false, }), @@ -47,13 +47,14 @@ const getTelemetryState = Instance.state( await shutdownTelemetry() state.activeSessions.clear() state.turnStartTimes.clear() - state.assistantToUser.clear() + state.recordedUserMessages.clear() state.unsubscribers = [] log.info("telemetry disposed") }, ) -// Cached user info from consent endpoint +// Cached user info from consent endpoint — shared across instances because +// the same qBraid account is used regardless of which project is open. let cachedUserInfo: { userId: string; organizationId?: string } | null = null /** @@ -145,12 +146,9 @@ export async function initTelemetryIntegration(): Promise { subscribeToEvents(state) state.initialized = true - // Flush pending data on process exit - const flushOnExit = () => { - shutdownTelemetry().catch(() => {}) - } - process.once("SIGTERM", flushOnExit) - process.once("beforeExit", flushOnExit) + // Note: flush on exit is handled by Instance.state disposal (getTelemetryState) + // which calls shutdownTelemetry(). We do NOT register process.once handlers + // here because they would run outside any Instance context and crash. log.info("telemetry integration initialized") } @@ -170,6 +168,8 @@ function subscribeToEvents(state: TelemetryState): void { const orgId = cachedUserInfo?.organizationId ?? "unknown" state.activeSessions.set(info.id, { startTime: Date.now(), userId, orgId }) + state.recordedUserMessages.clear() + state.turnStartTimes.clear() await collector.startSession(info.id, userId, orgId) log.debug("session tracking started", { sessionId: info.id }) }), @@ -185,23 +185,12 @@ function subscribeToEvents(state: TelemetryState): void { }), ) - // --- User messages --- - // We record user messages when we see a text part on a user message. - // MessageV2.Event.PartUpdated fires *after* the part is written to storage, - // avoiding the race where MessageV2.Event.Updated fires before parts exist. - - const recordedUserMessages = new Set() + // --- Tool calls via PartUpdated --- state.unsubscribers.push( Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { const { part } = event.properties - // Record user message text parts (deduped per message) - if (part.type === "text" && !recordedUserMessages.has(part.messageID)) { - // Check if this part belongs to a user message by looking up the message - // We defer this to the Updated event for user messages to avoid extra reads - } - // Handle completed tool calls if (part.type === "tool" && part.state.status === "completed") { const duration = part.state.time.end - part.state.time.start @@ -238,8 +227,8 @@ function subscribeToEvents(state: TelemetryState): void { state.turnStartTimes.set(info.id, Date.now()) // Only record content once per message - if (recordedUserMessages.has(info.id)) return - recordedUserMessages.add(info.id) + if (state.recordedUserMessages.has(info.id)) return + state.recordedUserMessages.add(info.id) // Read parts — by the time Updated fires for a user message on subsequent // updates (e.g. when the assistant starts), parts should be available. @@ -282,15 +271,13 @@ function subscribeToEvents(state: TelemetryState): void { const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") const content = textParts.map((p) => p.text).join("\n") - // Find the user message that started this turn. - // The most recent entry in turnStartTimes is the current turn's user message. + // Find the user message that started this turn via parentID. + // Assistant messages reference their parent user message. + const parentId = info.parentID let startTime = Date.now() - const entries = Array.from(state.turnStartTimes.entries()) - if (entries.length > 0) { - const last = entries[entries.length - 1] - startTime = last[1] - // Clean up old entries to prevent unbounded growth - state.turnStartTimes.delete(last[0]) + if (parentId && state.turnStartTimes.has(parentId)) { + startTime = state.turnStartTimes.get(parentId)! + state.turnStartTimes.delete(parentId) } const latencyMs = Date.now() - startTime @@ -358,6 +345,7 @@ function subscribeToEvents(state: TelemetryState): void { /** * Finalize a turn manually (for non-Event-Bus callers). + * Records the assistant message and queues the turn for upload. */ export function finalizeTurn( _sessionId: string, @@ -369,6 +357,7 @@ export function finalizeTurn( const collector = getCollector() const latencyMs = startTime ? Date.now() - startTime : 0 collector.recordAssistantMessage(assistantContent, modelId, tokens.input, tokens.output, latencyMs) + collector.finalizeTurn() } export function recordUserTurn(content: string, hasImages = false, hasFiles = false): void { @@ -394,7 +383,7 @@ export async function shutdownTelemetryIntegration(): Promise { state.activeSessions.clear() state.turnStartTimes.clear() - state.assistantToUser.clear() + state.recordedUserMessages.clear() state.initialized = false log.info("telemetry integration shutdown")