UNPKG

traceprompt-node

Version:

Client-side encrypted, audit-ready logging for LLM applications

315 lines (274 loc) 8.64 kB
import * as fs from "node:fs"; import * as path from "node:path"; import * as yaml from "yaml"; import { TracepromptInit } from "./types"; interface InternalTracePromptConfig extends TracepromptInit { orgId: string; // Always present after resolution cmkArn: string; // KMS key ARN for encryption hmacSecret: string; // HMAC secret for signing requests } interface WhoAmIResponse { success: boolean; data: { type: "organization"; orgId?: string; orgName?: string; kmsKeyArn?: string; scope: string; keyId: string; }; } interface HmacSecretResponse { success: boolean; data: { hmacSecret: string; keyId: string; }; } interface ErrorResponse { error?: string; message?: string; reason?: string; subscriptionStatus?: string; } /** * Fetch HMAC secret from API key */ async function fetchHmacSecret( apiKey: string, ingestUrl: string ): Promise<string> { try { const hmacUrl = `${ingestUrl.replace("/v1/ingest", "")}/v1/hmac-secret`; const response = await fetch(hmacUrl, { method: "GET", headers: { "x-api-key": apiKey, "Content-Type": "application/json", }, }); if (!response.ok) { let errorMessage = `${response.status} ${response.statusText}`; try { const errorBody = (await response.json()) as ErrorResponse; if (errorBody.message) { errorMessage = errorBody.message; } else if (errorBody.error) { errorMessage = errorBody.error; } } catch { // If we can't parse the error response, use the default message } throw new Error(errorMessage); } const result = (await response.json()) as HmacSecretResponse; if (!result.success || !result.data?.hmacSecret) { throw new Error("Invalid HMAC secret response"); } return result.data.hmacSecret; } catch (error: any) { // If it's already a formatted error message, don't wrap it again if ( error.message && !error.message.startsWith("Failed to fetch HMAC secret:") ) { throw error; } throw new Error(`Failed to fetch HMAC secret: ${error.message}`); } } /** * Auto-resolve organization info from API key * This is handled internally - users don't need to specify any IDs */ async function resolveOrgFromApiKey( apiKey: string, ingestUrl: string ): Promise<{ orgId: string; cmkArn?: string }> { try { const whoamiUrl = `${ingestUrl.replace("/v1/ingest", "")}/v1/whoami`; const response = await fetch(whoamiUrl, { method: "GET", headers: { "x-api-key": apiKey, "Content-Type": "application/json", }, }); if (!response.ok) { let errorMessage = `${response.status} ${response.statusText}`; try { const errorBody = (await response.json()) as ErrorResponse; if (errorBody.message) { errorMessage = errorBody.message; } else if (errorBody.error) { errorMessage = errorBody.error; } } catch { // If we can't parse the error response, use the default message } throw new Error(`Failed to resolve organization: ${errorMessage}`); } const result = (await response.json()) as WhoAmIResponse; if (!result.success) { throw new Error("Failed to resolve organization from API key"); } const orgId = result.data.orgId; if (!orgId) { throw new Error("No organization ID found in API key response"); } const cmkArn = result.data.kmsKeyArn; return { orgId, cmkArn }; } catch (error) { // If the error already has a descriptive message about organization resolution, pass it through if ( error instanceof Error && error.message.includes("Failed to resolve organization") ) { throw error; } throw new Error( `Failed to auto-resolve organization from API key: ${ error instanceof Error ? error.message : String(error) }` ); } } function readYaml(filePath: string): Partial<TracepromptInit> { try { const abs = path.resolve(process.cwd(), filePath); if (!fs.existsSync(abs)) return {}; const raw = fs.readFileSync(abs, "utf8"); return yaml.parse(raw) ?? {}; } catch { return {}; } } class ConfigManagerClass { private _cfg?: Required<InternalTracePromptConfig>; private _loadPromise?: Promise<void>; async load(userCfg: Partial<TracepromptInit> = {}): Promise<void> { if (this._cfg) return; // Prevent multiple concurrent loads if (this._loadPromise) { await this._loadPromise; return; } this._loadPromise = this._doLoad(userCfg); await this._loadPromise; } private async _doLoad(userCfg: Partial<TracepromptInit> = {}): Promise<void> { const fileCfg = process.env["TRACEPROMPT_RC"] ? readYaml(process.env["TRACEPROMPT_RC"]) : {}; const envCfg: Partial<TracepromptInit> = { ...(process.env["TRACEPROMPT_API_KEY"] && { apiKey: process.env["TRACEPROMPT_API_KEY"], }), ...(process.env["TRACEPROMPT_INGEST_URL"] && { ingestUrl: process.env["TRACEPROMPT_INGEST_URL"], }), ...(process.env["TRACEPROMPT_LOG_LEVEL"] && { logLevel: process.env["TRACEPROMPT_LOG_LEVEL"] as any, }), // Agent configuration from environment variables ...((process.env["TRACEPROMPT_AGENT_NAME"] || process.env["TRACEPROMPT_AGENT_ID"] || process.env["TRACEPROMPT_AGENT_VERSION"] || process.env["TRACEPROMPT_AGENT_KIND"] || process.env["TRACEPROMPT_POLICY_PROFILE"]) && { agent: { ...(process.env["TRACEPROMPT_AGENT_NAME"] && { name: process.env["TRACEPROMPT_AGENT_NAME"], }), ...(process.env["TRACEPROMPT_AGENT_ID"] && { id: process.env["TRACEPROMPT_AGENT_ID"], }), ...(process.env["TRACEPROMPT_AGENT_VERSION"] && { version: process.env["TRACEPROMPT_AGENT_VERSION"], }), ...(process.env["TRACEPROMPT_AGENT_KIND"] && { kind: process.env["TRACEPROMPT_AGENT_KIND"], }), ...(process.env["TRACEPROMPT_POLICY_PROFILE"] && { policy_profile: process.env["TRACEPROMPT_POLICY_PROFILE"], }), }, }), }; const merged: TracepromptInit = { apiKey: "", ingestUrl: "https://api.traceprompt.com/v1/ingest", batchSize: 25, flushIntervalMs: 2_000, staticMeta: {}, agent: { name: "default_agent", version: "1.0.0", kind: "custom", policy_profile: "default", }, logLevel: "info", ...fileCfg, ...envCfg, ...userCfg, }; // Validate required fields if (!merged.apiKey) throw new Error("Traceprompt: apiKey is required"); // Auto-resolve orgId, cmkArn, and HMAC secret from API key let orgId: string; let cmkArn: string; let hmacSecret: string; try { const resolved = await resolveOrgFromApiKey( merged.apiKey, merged.ingestUrl ); orgId = resolved.orgId; cmkArn = resolved.cmkArn!; } catch (error) { // If the error is already about organization resolution, pass it through to avoid repetition if ( error instanceof Error && error.message.includes("Failed to resolve organization") ) { throw error; } throw new Error( `Failed to resolve organization: ${ error instanceof Error ? error.message : String(error) }` ); } try { // Fetch HMAC secret for signing requests hmacSecret = await fetchHmacSecret(merged.apiKey, merged.ingestUrl); } catch (error) { // Pass through the original error message without wrapping throw error; } // Final validation if (merged.batchSize! <= 0) merged.batchSize = 25; if (merged.flushIntervalMs! <= 0) merged.flushIntervalMs = 2_000; // Create internal config with resolved orgId and HMAC secret this._cfg = { ...merged, orgId, cmkArn, hmacSecret, apiKey: merged.apiKey, ingestUrl: merged.ingestUrl, } as Required<InternalTracePromptConfig>; } get cfg(): Readonly<Required<InternalTracePromptConfig>> { if (!this._cfg) { throw new Error("Traceprompt: initTracePrompt() must be called first"); } return this._cfg; } } export async function initTracePrompt( cfg?: Partial<TracepromptInit> ): Promise<void> { await ConfigManager.load(cfg); } export const ConfigManager = new ConfigManagerClass();