UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

205 lines • 7.15 kB
/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import crypto from 'node:crypto'; import { logger } from '../../utils/logger.js'; const MAX_BUFFER_SIZE = 1000; const DEFAULT_CLEARCUT_ENDPOINT = 'https://play.googleapis.com/log?format=json_proto'; const DEFAULT_FLUSH_INTERVAL_MS = 15 * 60 * 1000; const LOG_SOURCE = 2839; const CLIENT_TYPE = 47; const MIN_RATE_LIMIT_WAIT_MS = 30_000; const REQUEST_TIMEOUT_MS = 30_000; const SHUTDOWN_TIMEOUT_MS = 5_000; const SESSION_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; export class ClearcutSender { #appVersion; #osType; #clearcutEndpoint; #flushIntervalMs; #includePidHeader; #sessionId; #sessionCreated; #buffer = []; #flushTimer = null; #isFlushing = false; #timerStarted = false; constructor(config) { this.#appVersion = config.appVersion; this.#osType = config.osType; this.#clearcutEndpoint = config.clearcutEndpoint ?? DEFAULT_CLEARCUT_ENDPOINT; this.#flushIntervalMs = config.forceFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; this.#includePidHeader = config.includePidHeader ?? false; this.#sessionId = crypto.randomUUID(); this.#sessionCreated = Date.now(); } enqueueEvent(event) { if (Date.now() - this.#sessionCreated > SESSION_ROTATION_INTERVAL_MS) { this.#sessionId = crypto.randomUUID(); this.#sessionCreated = Date.now(); } const eventToSend = { ...event, session_id: this.#sessionId, app_version: this.#appVersion, os_type: this.#osType, }; logger?.('Enqueing telemetry event', JSON.stringify(eventToSend, null, 2)); this.#addToBuffer(eventToSend); if (!this.#timerStarted) { this.#timerStarted = true; this.#scheduleFlush(this.#flushIntervalMs); } } async sendShutdownEvent() { if (this.#flushTimer) { clearTimeout(this.#flushTimer); this.#flushTimer = null; } const shutdownEvent = { server_shutdown: {}, }; this.enqueueEvent(shutdownEvent); try { await Promise.race([ this.#finalFlush(), new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)), ]); logger?.('Final flush completed'); } catch (error) { logger?.('Final flush failed:', error); } } async #flush() { if (this.#isFlushing) { return; } if (this.#buffer.length === 0) { this.#scheduleFlush(this.#flushIntervalMs); return; } this.#isFlushing = true; let nextDelayMs = this.#flushIntervalMs; // Optimistically remove events from buffer before sending. // This prevents race conditions where a simultaneous #finalFlush would include these same events. const eventsToSend = [...this.#buffer]; this.#buffer = []; try { const result = await this.#sendBatch(eventsToSend); if (result.success) { if (result.nextRequestWaitMs !== undefined) { nextDelayMs = Math.max(result.nextRequestWaitMs, MIN_RATE_LIMIT_WAIT_MS); } } else if (result.isPermanentError) { logger?.('Permanent error, dropped batch of', eventsToSend.length, 'events'); } else { // Transient error: Requeue events at the front of the buffer // to maintain order and retry them later. this.#buffer = [...eventsToSend, ...this.#buffer]; } } catch (error) { // Safety catch for unexpected errors, requeue events this.#buffer = [...eventsToSend, ...this.#buffer]; logger?.('Flush failed unexpectedly:', error); } finally { this.#isFlushing = false; this.#scheduleFlush(nextDelayMs); } } #addToBuffer(event) { if (this.#buffer.length >= MAX_BUFFER_SIZE) { this.#buffer.shift(); logger?.('Telemetry buffer overflow: dropped oldest event'); } this.#buffer.push({ event, timestamp: Date.now(), }); } #scheduleFlush(delayMs) { logger?.(`Scheduling flush in ${delayMs}`); if (this.#flushTimer) { clearTimeout(this.#flushTimer); } this.#flushTimer = setTimeout(() => { this.#flush().catch(err => { logger?.('Flush error:', err); }); }, delayMs); } async #sendBatch(events) { logger?.(`Sending batch of ${events.length}`); const requestBody = { log_source: LOG_SOURCE, request_time_ms: Date.now().toString(), client_info: { client_type: CLIENT_TYPE, }, log_event: events.map(({ event, timestamp }) => ({ event_time_ms: timestamp.toString(), source_extension_json: JSON.stringify(event), })), }; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await fetch(this.#clearcutEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', // Used in E2E tests to confirm that the watchdog process is killed ...(this.#includePidHeader ? { 'X-Watchdog-Pid': process.pid.toString() } : {}), }, body: JSON.stringify(requestBody), signal: controller.signal, }); clearTimeout(timeoutId); if (response.ok) { const data = (await response.json()); return { success: true, nextRequestWaitMs: data.next_request_wait_millis, }; } const status = response.status; if (status >= 500 || status === 429) { return { success: false }; } logger?.('Telemetry permanent error:', status); return { success: false, isPermanentError: true }; } catch { clearTimeout(timeoutId); return { success: false }; } } async #finalFlush() { if (this.#buffer.length === 0) { return; } const eventsToSend = [...this.#buffer]; await this.#sendBatch(eventsToSend); } stopForTesting() { if (this.#flushTimer) { clearTimeout(this.#flushTimer); this.#flushTimer = null; } this.#timerStarted = false; } get bufferSizeForTesting() { return this.#buffer.length; } } //# sourceMappingURL=ClearcutSender.js.map