|
| 1 | +import { SeverityNumber } from "@opentelemetry/api-logs"; |
| 2 | +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; |
| 3 | +import { resourceFromAttributes } from "@opentelemetry/resources"; |
| 4 | +import { |
| 5 | + BatchLogRecordProcessor, |
| 6 | + LoggerProvider, |
| 7 | +} from "@opentelemetry/sdk-logs"; |
| 8 | +import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; |
| 9 | +import type { StoredNotification } from "./types.js"; |
| 10 | +import { Logger } from "./utils/logger.js"; |
| 11 | + |
| 12 | +export interface OtelLogConfig { |
| 13 | + /** PostHog ingest host, e.g., "https://us.i.posthog.com" */ |
| 14 | + posthogHost: string; |
| 15 | + /** Project API key, e.g., "phc_xxx" */ |
| 16 | + apiKey: string; |
| 17 | + /** Batch flush interval in ms (default: 500) */ |
| 18 | + flushIntervalMs?: number; |
| 19 | + /** Override the logs endpoint path (default: /i/v1/agent-logs) */ |
| 20 | + logsPath?: string; |
| 21 | +} |
| 22 | + |
| 23 | +/** |
| 24 | + * Session context for resource attributes. |
| 25 | + * These are set once per OTEL logger instance and indexed via resource_fingerprint |
| 26 | + */ |
| 27 | +export interface SessionContext { |
| 28 | + /** Parent task grouping - all runs for a task share this */ |
| 29 | + taskId: string; |
| 30 | + /** Primary conversation identifier - all events in a run share this */ |
| 31 | + runId: string; |
| 32 | + /** Deployment environment - "local" for desktop, "cloud" for cloud sandbox */ |
| 33 | + deviceType?: "local" | "cloud"; |
| 34 | +} |
| 35 | + |
| 36 | +export class OtelLogWriter { |
| 37 | + private loggerProvider: LoggerProvider; |
| 38 | + private logger: ReturnType<LoggerProvider["getLogger"]>; |
| 39 | + private debugLogger: Logger; |
| 40 | + private sessionContext: SessionContext; |
| 41 | + |
| 42 | + constructor( |
| 43 | + config: OtelLogConfig, |
| 44 | + sessionContext: SessionContext, |
| 45 | + debugLogger?: Logger, |
| 46 | + ) { |
| 47 | + this.debugLogger = |
| 48 | + debugLogger ?? new Logger({ debug: false, prefix: "[OtelLogWriter]" }); |
| 49 | + this.sessionContext = sessionContext; |
| 50 | + |
| 51 | + const logsPath = config.logsPath ?? "/i/v1/agent-logs"; |
| 52 | + const exporter = new OTLPLogExporter({ |
| 53 | + url: `${config.posthogHost}${logsPath}`, |
| 54 | + headers: { Authorization: `Bearer ${config.apiKey}` }, |
| 55 | + }); |
| 56 | + |
| 57 | + const processor = new BatchLogRecordProcessor(exporter, { |
| 58 | + scheduledDelayMillis: config.flushIntervalMs ?? 500, |
| 59 | + }); |
| 60 | + |
| 61 | + // Resource attributes are set ONCE per session and indexed via resource_fingerprint |
| 62 | + // So we have fast queries by run_id/task_id in PostHog Logs UI |
| 63 | + this.loggerProvider = new LoggerProvider({ |
| 64 | + resource: resourceFromAttributes({ |
| 65 | + [ATTR_SERVICE_NAME]: "twig-agent", |
| 66 | + run_id: sessionContext.runId, |
| 67 | + task_id: sessionContext.taskId, |
| 68 | + device_type: sessionContext.deviceType ?? "local", |
| 69 | + }), |
| 70 | + processors: [processor], |
| 71 | + }); |
| 72 | + |
| 73 | + this.logger = this.loggerProvider.getLogger("agent-session"); |
| 74 | + } |
| 75 | + |
| 76 | + /** |
| 77 | + * Emit an agent event to PostHog Logs via OTEL. |
| 78 | + */ |
| 79 | + emit(entry: { notification: StoredNotification }): void { |
| 80 | + const { notification } = entry; |
| 81 | + const eventType = notification.notification.method; |
| 82 | + |
| 83 | + this.logger.emit({ |
| 84 | + severityNumber: SeverityNumber.INFO, |
| 85 | + severityText: "INFO", |
| 86 | + body: JSON.stringify(notification), |
| 87 | + attributes: { |
| 88 | + event_type: eventType, |
| 89 | + }, |
| 90 | + }); |
| 91 | + |
| 92 | + this.debugLogger.debug("Emitted OTEL log", { |
| 93 | + taskId: this.sessionContext.taskId, |
| 94 | + runId: this.sessionContext.runId, |
| 95 | + eventType, |
| 96 | + }); |
| 97 | + } |
| 98 | + |
| 99 | + async flush(): Promise<void> { |
| 100 | + await this.loggerProvider.forceFlush(); |
| 101 | + } |
| 102 | + |
| 103 | + async shutdown(): Promise<void> { |
| 104 | + await this.loggerProvider.shutdown(); |
| 105 | + } |
| 106 | +} |
0 commit comments