chore(telemetry): remove ai.* and gen_ai.* span emission

Drops the PostHogSpanExporter that converted AI SDK OpenTelemetry spans
into PostHog ai.* / gen_ai.* events, and disables AI SDK
experimental_telemetry in the CLI so those spans are no longer emitted.
Tool/session/indexing telemetry is unchanged.
This commit is contained in:
kiloconnect[bot]
2026-04-29 08:52:51 +00:00
parent 34787b4848
commit 0bf14eb2ff
10 changed files with 12 additions and 486 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Stop emitting `ai.*` and `gen_ai.*` OpenTelemetry spans from AI SDK calls, and remove the PostHog bridge that forwarded them. Tool/session/indexing telemetry is unchanged.
-6
View File
@@ -285,12 +285,6 @@
"version": "7.2.26",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/core": "2.6.1",
"@opentelemetry/resources": "2.6.1",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"posthog-node": "4.4.0",
},
"devDependencies": {
+1 -7
View File
@@ -18,13 +18,7 @@
},
"dependencies": {
"posthog-node": "4.4.0",
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/core": "2.6.1",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentelemetry/resources": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0"
"@kilocode/kilo-gateway": "workspace:*"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -2,21 +2,6 @@ import { describe, test, expect, beforeEach } from "bun:test"
import { Identity } from "../identity.js"
import { TelemetryEvent } from "../events.js"
import { Telemetry } from "../telemetry.js"
import { PostHogSpanExporter } from "../otel-exporter.js"
import { ExportResultCode } from "@opentelemetry/core"
import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
import type { PostHog } from "posthog-node"
function createMockPostHogClient(): PostHog {
return {
capture: () => {},
alias: () => {},
flush: async () => {},
shutdown: async () => {},
optIn: () => {},
optOut: () => {},
} as unknown as PostHog
}
describe("Identity", () => {
beforeEach(() => {
@@ -104,115 +89,3 @@ describe("Telemetry", () => {
expect(typeof Telemetry.trackIndexingError).toBe("function")
})
})
describe("PostHogSpanExporter", () => {
function createMockSpan(name: string, attributes: Record<string, unknown>): ReadableSpan {
return {
name,
attributes,
spanContext: () => ({
traceId: "trace-123",
spanId: "span-456",
traceFlags: 1,
}),
parentSpanContext: undefined,
startTime: [1000, 0],
endTime: [1001, 0],
status: { code: 0 },
kind: 0,
resource: { attributes: {} },
instrumentationScope: { name: "test" },
events: [],
links: [],
ended: true,
duration: [1, 0],
droppedAttributesCount: 0,
droppedEventsCount: 0,
droppedLinksCount: 0,
} as unknown as ReadableSpan
}
test("export returns success when disabled", () => {
const exporter = new PostHogSpanExporter(createMockPostHogClient(), {
appName: "test",
appVersion: "1.0.0",
platform: "test",
})
exporter.setEnabled(false)
const span = createMockSpan("ai.generateText", { "ai.model.id": "gpt-4" })
let result: { code: number } | null = null
exporter.export([span], (r) => {
result = r
})
expect(result).not.toBeNull()
expect(result!.code).toBe(ExportResultCode.SUCCESS)
})
test("sensitive attributes are not included in exported properties", () => {
// This test verifies the filtering logic by checking the SENSITIVE_ATTRIBUTES set
// and the mapAttributes method behavior through the export function
const exporter = new PostHogSpanExporter(createMockPostHogClient(), {
appName: "test",
appVersion: "1.0.0",
platform: "test",
})
// Create a span with both safe and sensitive attributes
const span = createMockSpan("ai.generateText", {
// Safe attributes (should be passed through)
"ai.model.id": "gpt-4",
"ai.model.provider": "openai",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.input_tokens": 100,
"gen_ai.usage.output_tokens": 50,
// Sensitive attributes (should be filtered)
"ai.prompt": '{"messages": [{"role": "user", "content": "secret data"}]}',
"ai.prompt.messages": '[{"role": "user", "content": "secret"}]',
"ai.response.text": "This is a secret response",
"ai.toolCall.args": '{"secret": "value"}',
"ai.toolCall.result": '{"result": "secret"}',
"gen_ai.prompt": "secret prompt",
"gen_ai.completion": "secret completion",
})
// The exporter should complete successfully
let result: { code: number } | null = null
exporter.export([span], (r) => {
result = r
})
expect(result).not.toBeNull()
expect(result!.code).toBe(ExportResultCode.SUCCESS)
})
test("SENSITIVE_ATTRIBUTES blocklist contains all required patterns", () => {
// Verify all sensitive attribute patterns are in the blocklist
const sensitivePatterns = [
"ai.prompt",
"ai.prompt.messages",
"ai.response.text",
"ai.response.toolCalls",
"ai.toolCall.args",
"ai.toolCall.result",
"ai.value",
"ai.values",
"ai.embedding",
"ai.embeddings",
"ai.prompt.tools",
"gen_ai.prompt",
"gen_ai.completion",
"gen_ai.input.messages",
"gen_ai.output.messages",
"gen_ai.system_instructions",
"gen_ai.tool.definitions",
]
// Import the module to check the exported constant exists
// Since SENSITIVE_ATTRIBUTES is not exported, we verify through behavior
// by ensuring the exporter handles these attributes correctly
expect(sensitivePatterns.length).toBe(17)
})
})
-2
View File
@@ -1,6 +1,4 @@
export { Telemetry } from "./telemetry.js"
export { TelemetryEvent } from "./events.js"
export { Identity } from "./identity.js"
export { TracerSetup } from "./tracer.js"
export { PostHogSpanExporter } from "./otel-exporter.js"
export type { TelemetryProperties } from "./telemetry.js"
@@ -1,231 +0,0 @@
import type { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base"
import type { ExportResult } from "@opentelemetry/core"
import { ExportResultCode } from "@opentelemetry/core"
import type { PostHog } from "posthog-node"
import { Identity } from "./identity.js"
/**
* Sensitive attributes that should never be sent to PostHog.
* This is a defense-in-depth measure - the AI SDK should already filter these
* when recordInputs/recordOutputs are set to false.
*/
const SENSITIVE_ATTRIBUTES = new Set([
// AI SDK prompt/message content
"ai.prompt",
"ai.prompt.messages",
"ai.response.text",
"ai.response.toolCalls",
"ai.toolCall.args",
"ai.toolCall.result",
"ai.value",
"ai.values",
"ai.embedding",
"ai.embeddings",
"ai.prompt.tools",
// OpenTelemetry GenAI semantic conventions
"gen_ai.prompt",
"gen_ai.completion",
"gen_ai.input.messages",
"gen_ai.output.messages",
"gen_ai.system_instructions",
"gen_ai.tool.definitions",
])
/**
* PostHogSpanExporter converts OpenTelemetry spans to PostHog AI events.
* Maps OTel spans to PostHog's $ai_span, $ai_generation, and $ai_trace events.
*/
export class PostHogSpanExporter implements SpanExporter {
private client: PostHog
private enabled = true
private appName: string
private appVersion: string
private platform: string
private editorName?: string
private vscodeVersion?: string
constructor(
client: PostHog,
options: { appName: string; appVersion: string; platform: string; editorName?: string; vscodeVersion?: string },
) {
this.client = client
this.appName = options.appName
this.appVersion = options.appVersion
this.platform = options.platform
this.editorName = options.editorName
this.vscodeVersion = options.vscodeVersion
}
setEnabled(value: boolean) {
this.enabled = value
if (value) this.client.optIn()
else this.client.optOut()
}
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
if (!this.enabled) {
resultCallback({ code: ExportResultCode.SUCCESS })
return
}
for (const span of spans) {
this.exportSpan(span)
}
resultCallback({ code: ExportResultCode.SUCCESS })
}
private exportSpan(span: ReadableSpan) {
const distinctId = Identity.getDistinctId()
const orgId = Identity.getOrganizationId()
const name = span.name
const attrs = span.attributes
const duration = span.endTime[0] - span.startTime[0] + (span.endTime[1] - span.startTime[1]) / 1e9
// Determine event type based on span name/attributes
const eventType = this.determineEventType(name, attrs)
// Build PostHog AI event properties
const properties: Record<string, unknown> = {
appName: this.appName,
appVersion: this.appVersion,
platform: this.platform,
...(this.editorName && { editorName: this.editorName }),
...(this.vscodeVersion && { vscodeVersion: this.vscodeVersion }),
$ai_trace_id: span.spanContext().traceId,
$ai_span_id: span.spanContext().spanId,
$ai_span_name: name,
$ai_latency: duration,
...(orgId && { kilocodeOrganizationId: orgId }),
}
// Add parent ID if present
const parentSpanId = span.parentSpanContext?.spanId
if (parentSpanId) {
properties.$ai_parent_id = parentSpanId
}
// Map span attributes to PostHog properties
this.mapAttributes(attrs, properties)
// Handle errors
if (span.status.code === 2) {
// SpanStatusCode.ERROR
properties.$ai_is_error = true
properties.$ai_error = span.status.message || "Unknown error"
}
// Capture the event
this.client.capture({
distinctId,
event: eventType,
properties,
})
}
private determineEventType(name: string, attrs: Record<string, unknown>): string {
// AI SDK spans typically have these patterns:
// - "ai.generateText" / "ai.streamText" -> $ai_generation
// - "ai.toolCall" -> $ai_span (tool call)
// - "ai.embed" -> $ai_span (embedding)
const lowerName = name.toLowerCase()
if (
lowerName.includes("generatetext") ||
lowerName.includes("streamtext") ||
lowerName.includes("generateobject") ||
lowerName.includes("streamobject")
) {
return "$ai_generation"
}
if (lowerName.includes("toolcall") || lowerName.includes("tool")) {
return "$ai_span"
}
if (lowerName.includes("embed")) {
return "$ai_span"
}
// Check for gen_ai semantic conventions
if (attrs["gen_ai.operation.name"]) {
const op = String(attrs["gen_ai.operation.name"])
if (op === "chat" || op === "text_completion") {
return "$ai_generation"
}
}
// Default to span for other AI operations
return "$ai_span"
}
private mapAttributes(attrs: Record<string, unknown>, props: Record<string, unknown>) {
// Map OpenTelemetry semantic conventions to PostHog AI properties
// Model info
if (attrs["gen_ai.request.model"]) {
props.$ai_model = attrs["gen_ai.request.model"]
}
if (attrs["gen_ai.system"]) {
props.$ai_provider = attrs["gen_ai.system"]
}
// Token usage
if (attrs["gen_ai.usage.input_tokens"]) {
props.$ai_input_tokens = attrs["gen_ai.usage.input_tokens"]
}
if (attrs["gen_ai.usage.output_tokens"]) {
props.$ai_output_tokens = attrs["gen_ai.usage.output_tokens"]
}
if (attrs["gen_ai.usage.total_tokens"]) {
props.$ai_total_tokens = attrs["gen_ai.usage.total_tokens"]
}
// Track presence of prompt/completion without content (for analytics)
if (attrs["gen_ai.prompt"] || attrs["ai.prompt"]) {
props.$ai_has_prompt = true
}
if (attrs["gen_ai.completion"] || attrs["ai.response.text"]) {
props.$ai_has_completion = true
}
// Tool calls - only track name, not args/result
if (attrs["ai.toolCall.name"]) {
props.$ai_tool_name = attrs["ai.toolCall.name"]
}
// Temperature and other settings
if (attrs["gen_ai.request.temperature"]) {
props.$ai_temperature = attrs["gen_ai.request.temperature"]
}
if (attrs["gen_ai.request.max_tokens"]) {
props.$ai_max_tokens = attrs["gen_ai.request.max_tokens"]
}
// Finish reason
if (attrs["gen_ai.response.finish_reasons"]) {
props.$ai_finish_reason = attrs["gen_ai.response.finish_reasons"]
}
// Copy safe ai.* prefixed attributes (filter out sensitive ones)
for (const [key, value] of Object.entries(attrs)) {
if (!key.startsWith("ai.")) continue
if (SENSITIVE_ATTRIBUTES.has(key)) continue
// Additional pattern-based filtering for safety
if (key.includes("prompt") || key.includes("messages") || key.includes("response.text")) continue
const propKey = key.replace(/\./g, "_")
props[propKey] = value
}
}
async shutdown(): Promise<void> {
// Only flush, don't shutdown - the shared client is managed by Client namespace
await this.client.flush()
}
async forceFlush(): Promise<void> {
await this.client.flush()
}
}
-22
View File
@@ -1,8 +1,6 @@
import { Client } from "./client.js"
import { Identity } from "./identity.js"
import { TelemetryEvent } from "./events.js"
import { TracerSetup } from "./tracer.js"
import type { Tracer } from "@opentelemetry/api"
export interface TelemetryProperties {
appName: string
@@ -83,16 +81,6 @@ export namespace Telemetry {
const enabled = level ? level === "all" : options.enabled
Client.setEnabled(enabled)
// Initialize OpenTelemetry tracer for AI SDK spans
TracerSetup.init({
version: props.appVersion,
enabled,
appName: props.appName,
platform: props.platform,
editorName: props.editorName,
vscodeVersion: props.vscodeVersion,
})
await Identity.getMachineId()
initialized = true
@@ -101,15 +89,6 @@ export namespace Telemetry {
export function setEnabled(value: boolean) {
Client.setEnabled(value)
TracerSetup.setEnabled(value)
}
/**
* Get the OpenTelemetry tracer for use with AI SDK's experimental_telemetry.
* Returns null if telemetry is not initialized.
*/
export function getTracer(): Tracer | null {
return TracerSetup.getTracer()
}
export function isEnabled(): boolean {
@@ -263,7 +242,6 @@ export namespace Telemetry {
}
export async function shutdown(): Promise<void> {
await TracerSetup.shutdown()
await Client.shutdown()
}
}
-71
View File
@@ -1,71 +0,0 @@
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
import { resourceFromAttributes } from "@opentelemetry/resources"
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
import type { Tracer } from "@opentelemetry/api"
import { PostHogSpanExporter } from "./otel-exporter.js"
import { Client } from "./client.js"
let provider: NodeTracerProvider | null = null
let exporter: PostHogSpanExporter | null = null
let tracer: Tracer | null = null
export namespace TracerSetup {
export function init(options: {
version: string
enabled: boolean
appName: string
platform: string
editorName?: string
vscodeVersion?: string
}): Tracer {
if (tracer) return tracer
const client = Client.getClient()
if (!client) {
throw new Error("PostHog client not initialized. Call Client.init() first.")
}
exporter = new PostHogSpanExporter(client, {
appName: options.appName,
appVersion: options.version,
platform: options.platform,
editorName: options.editorName,
vscodeVersion: options.vscodeVersion,
})
exporter.setEnabled(options.enabled)
provider = new NodeTracerProvider({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: options.appName,
[ATTR_SERVICE_VERSION]: options.version,
}),
spanProcessors: [new SimpleSpanProcessor(exporter)],
})
// Register the provider globally so all tracers use our exporter
provider.register()
// Get tracer from our provider
tracer = provider.getTracer(options.appName, options.version)
return tracer
}
export function getTracer(): Tracer | null {
return tracer
}
export function setEnabled(value: boolean) {
exporter?.setEnabled(value)
}
export async function shutdown(): Promise<void> {
if (provider) {
await provider.shutdown()
provider = null
tracer = null
exporter = null
}
}
}
+3 -11
View File
@@ -7,7 +7,6 @@ import { Config } from "../../config"
import { Instance } from "../../project/instance"
import { makeRuntime } from "@/effect/run-service"
import { Global } from "@/global"
import { Telemetry } from "@kilocode/kilo-telemetry"
import z from "zod"
import path from "path"
@@ -173,16 +172,9 @@ export function processConfigItem(item: {
}
// Returns experimental_telemetry config for generate calls.
export function telemetryOptions(cfg: Config.Info) {
return {
isEnabled: cfg.experimental?.openTelemetry !== false,
recordInputs: false,
recordOutputs: false,
tracer: Telemetry.getTracer() ?? undefined,
metadata: {
userId: cfg.username ?? "unknown",
},
}
// AI SDK span recording (ai.* / gen_ai.*) is disabled.
export function telemetryOptions(_cfg: Config.Info) {
return { isEnabled: false as const }
}
// Patch the base agents map in-place with all kilo-specific changes:
+2 -9
View File
@@ -20,7 +20,6 @@ import { Wildcard } from "@/util"
import { SessionID } from "@/session/schema"
import { Auth } from "@/auth"
// kilocode_change start
import { Telemetry } from "@kilocode/kilo-telemetry"
import { DEFAULT_HEADERS } from "@/kilocode/const"
import { getKiloProjectId } from "@/kilocode/project-id"
import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway"
@@ -430,14 +429,8 @@ const live: Layer.Layer<
},
],
}),
// kilocode_change start - enable telemetry by default with custom PostHog tracer
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry !== false,
recordInputs: false,
recordOutputs: false,
tracer: Telemetry.getTracer() ?? undefined,
},
// kilocode_change end
// kilocode_change - disable AI SDK span recording (ai.* / gen_ai.*)
experimental_telemetry: { isEnabled: false },
})
})