// Langfuse trace forwarding for completed agent runs.
//
// This module is intentionally dependency-free (no `langfuse` SDK). It builds
// Langfuse ingestion batches for completed runs and sends them either to the
// official Open Design telemetry relay or, for local smoke tests, directly to
// Langfuse. Without OPEN_DESIGN_TELEMETRY_RELAY_URL or LANGFUSE_PUBLIC_KEY /
// LANGFUSE_SECRET_KEY in the env, every entry point becomes a no-op so that
// dev runs and forks of this open-source repo do not accidentally report.
//
// Privacy gates are layered: `prefs.metrics` is the master switch, and
// `prefs.content` is required for Langfuse traces because this sink is used
// for turn-quality evals. If either is off, no network call is made.
// `prefs.artifactManifest` decides whether the produced-files manifest is
// included. None of these defaults to true; the Web onboarding flow flips
// metrics + content after explicit consent.
//
// See: specs/change/20260507-langfuse-telemetry/spec.md
import { randomUUID } from 'node:crypto';
// Langfuse US region: confirmed by an end-to-end smoke on 2026-05-07 — the
// project's keys authenticate against `us.cloud.langfuse.com` only. EU host
// (`cloud.langfuse.com`) returns 401 with the matching error message.
// See specs/change/20260507-langfuse-telemetry/spec.md Q3.
const DEFAULT_BASE_URL = 'https://us.cloud.langfuse.com';
const INPUT_MAX_BYTES = 8 * 1024;
const OUTPUT_MAX_BYTES = 16 * 1024;
const TOOL_INPUT_MAX_BYTES = 4 * 1024;
const TOOL_OUTPUT_MAX_BYTES = 4 * 1024;
const ARTIFACTS_MAX_ITEMS = 50;
const SESSION_ID_MAX = 200; // Langfuse drops sessionIds longer than this.
const HARD_BATCH_MAX_BYTES = 1024 * 1024;
const DEFAULT_FETCH_TIMEOUT_MS = 20_000;
const DEFAULT_FETCH_RETRIES = 1;
let missingTelemetrySinkWarned = false;
export function readLangfuseConfig(env = process.env) {
    const publicKey = env.LANGFUSE_PUBLIC_KEY?.trim();
    const secretKey = env.LANGFUSE_SECRET_KEY?.trim();
    if (!publicKey || !secretKey)
        return null;
    const baseUrl = (env.LANGFUSE_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, '');
    const authHeader = 'Basic ' +
        Buffer.from(`${publicKey}:${secretKey}`, 'utf8').toString('base64');
    return {
        authHeader,
        baseUrl,
        timeoutMs: parsePositiveInt(env.LANGFUSE_TIMEOUT_MS, DEFAULT_FETCH_TIMEOUT_MS),
        retries: parseNonNegativeInt(env.LANGFUSE_RETRIES, DEFAULT_FETCH_RETRIES),
    };
}
/**
 * Resolve telemetry delivery in release-safe order: hosted relay first,
 * direct Langfuse credentials second for local smoke tests, disabled last.
 */
export function readTelemetrySinkConfig(env = process.env) {
    const relayUrl = env.OPEN_DESIGN_TELEMETRY_RELAY_URL?.trim();
    if (relayUrl) {
        return {
            kind: 'relay',
            relayUrl: relayUrl.replace(/\/+$/, ''),
            timeoutMs: parsePositiveInt(env.OPEN_DESIGN_TELEMETRY_TIMEOUT_MS ?? env.LANGFUSE_TIMEOUT_MS, DEFAULT_FETCH_TIMEOUT_MS),
            retries: parseNonNegativeInt(env.OPEN_DESIGN_TELEMETRY_RETRIES ?? env.LANGFUSE_RETRIES, DEFAULT_FETCH_RETRIES),
        };
    }
    const config = readLangfuseConfig(env);
    return config == null ? null : { kind: 'langfuse', ...config };
}
function parsePositiveInt(value, fallback) {
    if (value === undefined)
        return fallback;
    const parsed = Number.parseInt(value, 10);
    return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function parseNonNegativeInt(value, fallback) {
    if (value === undefined)
        return fallback;
    const parsed = Number.parseInt(value, 10);
    return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
// Byte-aware UTF-8 truncation. JS String.length counts UTF-16 code units,
// not bytes — non-ASCII text (CJK, emoji) can occupy 2-4× as many bytes as
// characters, so a `value.length > max` cap silently lets oversized prompts
// through. We truncate on a UTF-8 byte boundary so the result is still
// valid Unicode (no half-encoded characters).
function truncate(value, maxBytes) {
    if (!value)
        return undefined;
    const buf = Buffer.from(value, 'utf8');
    if (buf.length <= maxBytes)
        return value;
    let cut = maxBytes;
    // UTF-8 continuation bytes have the bit pattern 10xxxxxx. Walk backwards
    // until we land on a leading byte (0xxxxxxx, 110xxxxx, 1110xxxx, 11110xxx)
    // so the slice doesn't end mid-character.
    while (cut > 0 && (buf[cut] & 0xc0) === 0x80)
        cut -= 1;
    return buf.subarray(0, cut).toString('utf8');
}
function buildTagList(ctx) {
    const tags = ['open-design', `project:${ctx.projectId}`];
    if (ctx.agentId)
        tags.push(`agent:${ctx.agentId}`);
    if (ctx.turn?.model)
        tags.push(`model:${ctx.turn.model}`);
    if (ctx.turn?.skillId)
        tags.push(`skill:${ctx.turn.skillId}`);
    if (ctx.turn?.designSystemId)
        tags.push(`ds:${ctx.turn.designSystemId}`);
    if (ctx.runtime?.os)
        tags.push(`os:${ctx.runtime.os}`);
    if (ctx.runtime?.clientType && ctx.runtime.clientType !== 'unknown') {
        tags.push(`client:${ctx.runtime.clientType}`);
    }
    if (ctx.extraTags?.length)
        tags.push(...ctx.extraTags);
    return tags;
}
export function buildTracePayload(ctx) {
    const wantsContent = ctx.prefs.content === true;
    const wantsArtifacts = ctx.prefs.artifactManifest === true;
    const sessionId = ctx.conversationId.length <= SESSION_ID_MAX ? ctx.conversationId : undefined;
    const startTimeIso = new Date(ctx.run.startedAt).toISOString();
    const endTimeIso = new Date(ctx.run.endedAt).toISOString();
    const nowIso = new Date().toISOString();
    const inputText = wantsContent
        ? truncate(ctx.message.prompt, INPUT_MAX_BYTES)
        : undefined;
    const outputText = wantsContent
        ? truncate(ctx.message.output, OUTPUT_MAX_BYTES)
        : undefined;
    const artifactsList = wantsArtifacts
        ? ctx.artifacts.slice(0, ARTIFACTS_MAX_ITEMS)
        : undefined;
    const artifactsTruncated = wantsArtifacts && ctx.artifacts.length > ARTIFACTS_MAX_ITEMS
        ? true
        : undefined;
    const tokens = ctx.message.usage
        ? {
            input: ctx.message.usage.inputTokens,
            output: ctx.message.usage.outputTokens,
            total: ctx.message.usage.totalTokens,
        }
        : undefined;
    const usage = ctx.message.usage
        ? {
            input: ctx.message.usage.inputTokens,
            output: ctx.message.usage.outputTokens,
            total: ctx.message.usage.totalTokens,
            unit: 'TOKENS',
        }
        : undefined;
    const success = ctx.run.status === 'succeeded';
    const traceId = ctx.run.runId;
    const agentSpanId = `${ctx.run.runId}-agent`;
    const generationId = `${ctx.run.runId}-gen`;
    // Trace metadata is the queryable + exportable fact-sheet for each turn.
    // Anything we want to slice on for evals or dataset construction lives
    // here. Fields are flat (Langfuse stores it as JSON but indexes shallow
    // keys best). All entries are anonymous — no PII, no credentials.
    const traceMetadata = {
        success,
        status: ctx.run.status,
        error: ctx.run.error ?? undefined,
        eventsSummary: ctx.eventsSummary,
        tokens,
        artifacts: artifactsList,
        artifactsTruncated,
        projectId: ctx.projectId || undefined,
        agent: ctx.agentId,
        model: ctx.turn?.model,
        reasoning: ctx.turn?.reasoning,
        skillId: ctx.turn?.skillId,
        designSystemId: ctx.turn?.designSystemId,
        appVersion: ctx.runtime?.appVersion,
        appChannel: ctx.runtime?.appChannel,
        packaged: ctx.runtime?.packaged,
        nodeVersion: ctx.runtime?.nodeVersion,
        os: ctx.runtime?.os,
        osRelease: ctx.runtime?.osRelease,
        arch: ctx.runtime?.arch,
        clientType: ctx.runtime?.clientType,
    };
    // Generation-level model parameters mirror the Langfuse schema so the UI
    // shows them in the dedicated Model Parameters card and filters work.
    const modelParameters = ctx.turn?.reasoning ? { reasoning: ctx.turn.reasoning } : undefined;
    const batch = [
        {
            id: randomUUID(),
            type: 'trace-create',
            timestamp: nowIso,
            body: {
                id: traceId,
                name: 'open-design-turn',
                sessionId,
                userId: ctx.installationId ?? undefined,
                tags: buildTagList(ctx),
                input: inputText,
                output: outputText,
                metadata: traceMetadata,
                timestamp: startTimeIso,
            },
        },
        {
            id: randomUUID(),
            type: 'span-create',
            timestamp: nowIso,
            body: {
                id: agentSpanId,
                traceId,
                name: 'agent-run',
                startTime: startTimeIso,
                endTime: endTimeIso,
                input: inputText,
                output: outputText,
                level: success ? 'DEFAULT' : 'ERROR',
                statusMessage: ctx.run.error ?? undefined,
                metadata: {
                    status: ctx.run.status,
                    messageId: ctx.message.messageId || undefined,
                    durationMs: ctx.eventsSummary.durationMs,
                    toolCalls: ctx.eventsSummary.toolCalls,
                    errors: ctx.eventsSummary.errors,
                },
            },
        },
        {
            id: randomUUID(),
            type: 'generation-create',
            timestamp: nowIso,
            body: {
                id: generationId,
                traceId,
                parentObservationId: agentSpanId,
                name: 'llm',
                // model / modelParameters are first-class on Langfuse generations
                // (used for token-cost lookup, UI grouping, eval filters), so set
                // them at the body level instead of stuffing them into metadata.
                model: ctx.turn?.model,
                modelParameters,
                startTime: startTimeIso,
                endTime: endTimeIso,
                input: inputText,
                output: outputText,
                level: success ? 'DEFAULT' : 'ERROR',
                statusMessage: ctx.run.error ?? undefined,
                usage,
                metadata: {
                    durationMs: ctx.eventsSummary.durationMs,
                },
            },
        },
    ];
    if (ctx.tools?.length) {
        for (const tool of ctx.tools) {
            const toolSpanId = `${ctx.run.runId}-tool-${tool.id}`;
            const toolStartedAt = new Date(tool.startedAt).toISOString();
            const toolEndedAt = new Date(tool.endedAt).toISOString();
            const toolInput = wantsContent
                ? truncate(tool.input, TOOL_INPUT_MAX_BYTES)
                : undefined;
            const toolOutput = wantsContent
                ? truncate(tool.output, TOOL_OUTPUT_MAX_BYTES)
                : undefined;
            batch.push({
                id: randomUUID(),
                type: 'span-create',
                timestamp: nowIso,
                body: {
                    id: toolSpanId,
                    traceId,
                    parentObservationId: agentSpanId,
                    name: `tool:${tool.name}`,
                    startTime: toolStartedAt,
                    endTime: toolEndedAt,
                    input: toolInput,
                    output: toolOutput,
                    level: tool.isError ? 'ERROR' : 'DEFAULT',
                    metadata: {
                        toolCallId: tool.id,
                        toolName: tool.name,
                        hasInput: tool.input !== undefined,
                        hasOutput: tool.output !== undefined,
                        isError: tool.isError === true,
                    },
                },
            });
        }
    }
    if (artifactsList && (artifactsList.length > 0 || artifactsTruncated)) {
        batch.push({
            id: randomUUID(),
            type: 'event-create',
            timestamp: nowIso,
            body: {
                id: `${ctx.run.runId}-artifacts`,
                traceId,
                parentObservationId: agentSpanId,
                name: 'artifact-summary',
                startTime: endTimeIso,
                metadata: {
                    artifacts: artifactsList,
                    artifactsTruncated,
                },
            },
        });
    }
    if (!success || ctx.eventsSummary.errors > 0) {
        batch.push({
            id: randomUUID(),
            type: 'event-create',
            timestamp: nowIso,
            body: {
                id: `${ctx.run.runId}-error`,
                traceId,
                parentObservationId: agentSpanId,
                name: success ? 'error-summary' : 'run-error',
                startTime: endTimeIso,
                level: 'ERROR',
                statusMessage: ctx.run.error ?? undefined,
                metadata: {
                    status: ctx.run.status,
                    errors: ctx.eventsSummary.errors,
                },
            },
        });
    }
    return batch;
}
async function postLangfuseBatch(config, batch, fetchImpl) {
    const attempts = config.retries + 1;
    for (let attempt = 1; attempt <= attempts; attempt += 1) {
        try {
            const response = await fetchImpl(`${config.baseUrl}/api/public/ingestion`, {
                method: 'POST',
                headers: {
                    Authorization: config.authHeader,
                    'Content-Type': 'application/json',
                },
                signal: AbortSignal.timeout(config.timeoutMs),
                body: JSON.stringify({ batch }),
            });
            if (!response.ok) {
                const body = await response.text().catch(() => '');
                if (attempt < attempts &&
                    (response.status === 429 || response.status >= 500)) {
                    await waitBeforeRetry(attempt);
                    continue;
                }
                console.warn(`[langfuse-trace] Ingestion failed ${response.status}: ${body.slice(0, 200)}`);
                return;
            }
            // Langfuse legacy ingestion responds with HTTP 207 Multi-Status whose
            // body shape is `{ successes: [...], errors: [...] }`. `response.ok`
            // is true for 207, so per-event validation errors slip through unless
            // we look at the body. Surface them so a malformed payload doesn't
            // silently disappear server-side.
            const body = await response.text().catch(() => '');
            if (!body)
                return;
            warnPerEventErrors(body, 'Per-event errors');
            return;
        }
        catch (error) {
            if (attempt < attempts) {
                await waitBeforeRetry(attempt);
                continue;
            }
            console.warn(`[langfuse-trace] Fetch error: ${String(error)}`);
            return;
        }
    }
}
async function postRelayBatch(config, body, fetchImpl) {
    const attempts = config.retries + 1;
    for (let attempt = 1; attempt <= attempts; attempt += 1) {
        try {
            const response = await fetchImpl(config.relayUrl, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Open-Design-Telemetry': 'langfuse-ingestion-v1',
                },
                signal: AbortSignal.timeout(config.timeoutMs),
                body,
            });
            if (!response.ok) {
                const responseBody = await response.text().catch(() => '');
                if (attempt < attempts &&
                    (response.status === 429 || response.status >= 500)) {
                    await waitBeforeRetry(attempt);
                    continue;
                }
                console.warn(`[langfuse-trace] Relay failed ${response.status}: ${responseBody.slice(0, 200)}`);
                return;
            }
            const responseBody = await response.text().catch(() => '');
            if (!responseBody)
                return;
            warnPerEventErrors(responseBody, 'Relay per-event errors');
            return;
        }
        catch (error) {
            if (attempt < attempts) {
                await waitBeforeRetry(attempt);
                continue;
            }
            console.warn(`[langfuse-trace] Relay fetch error: ${String(error)}`);
            return;
        }
    }
}
function waitBeforeRetry(attempt) {
    return new Promise((resolve) => setTimeout(resolve, Math.min(250 * attempt, 1000)));
}
function normalizeTelemetrySinkConfig(config) {
    if ('kind' in config)
        return config;
    return { kind: 'langfuse', ...config };
}
function resolveReportConfig(opts) {
    if (opts.config === undefined)
        return readTelemetrySinkConfig();
    if (opts.config == null)
        return null;
    return normalizeTelemetrySinkConfig(opts.config);
}
function warnPerEventErrors(responseBody, label) {
    let parsed;
    try {
        parsed = JSON.parse(responseBody);
    }
    catch {
        return;
    }
    const errors = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
        ? parsed.errors
        : undefined;
    if (Array.isArray(errors) && errors.length > 0) {
        console.warn(`[langfuse-trace] ${label} (${errors.length}): ${JSON.stringify(errors).slice(0, 500)}`);
    }
}
export async function reportRunCompleted(ctx, opts = {}) {
    if (ctx.prefs.metrics !== true)
        return;
    if (ctx.prefs.content !== true)
        return;
    const config = resolveReportConfig(opts);
    if (!config) {
        if (!missingTelemetrySinkWarned) {
            // Warn once per daemon process; packaged config is loaded at process
            // start, so repeated run-level warnings would only add noise.
            missingTelemetrySinkWarned = true;
            console.warn('[langfuse-trace] Telemetry metrics are enabled but no relay or Langfuse credentials are configured');
        }
        return;
    }
    let batch;
    try {
        batch = buildTracePayload(ctx);
    }
    catch (error) {
        console.warn(`[langfuse-trace] Payload build error: ${String(error)}`);
        return;
    }
    const serialized = JSON.stringify({ batch });
    // Compare actual UTF-8 byte length, not String.length (UTF-16 code units),
    // so the cap matches the byte-oriented contract documented in the spec
    // (and the byte-oriented limit Langfuse enforces server-side).
    const serializedBytes = Buffer.byteLength(serialized, 'utf8');
    if (serializedBytes > HARD_BATCH_MAX_BYTES) {
        console.warn(`[langfuse-trace] Batch too large (${serializedBytes}B > ${HARD_BATCH_MAX_BYTES}B), dropping trace ${ctx.run.runId}`);
        return;
    }
    const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
    if (config.kind === 'relay') {
        await postRelayBatch(config, serialized, fetchImpl);
        return;
    }
    await postLangfuseBatch(config, batch, fetchImpl);
}
//# sourceMappingURL=langfuse-trace.js.map