diff --git a/apps/landing/package.json b/apps/landing/package.json index a995efe5044..758f17c0fc4 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -7,7 +7,8 @@ "dev": "next dev --turbopack --port 3100", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" }, "dependencies": { "@radix-ui/react-slot": "^1.2.4", @@ -25,6 +26,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", "tailwindcss": "^4.1.8", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/landing/src/app/api/otel/traces/route.ts b/apps/landing/src/app/api/otel/traces/route.ts new file mode 100644 index 00000000000..ef601a204d7 --- /dev/null +++ b/apps/landing/src/app/api/otel/traces/route.ts @@ -0,0 +1,28 @@ +import { ingestOtelTraces } from "~/lib/otelIngest"; + +export const runtime = "nodejs"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "content-type, x-marcode-jira-access-token", + "Access-Control-Max-Age": "600", +} as const; + +export function OPTIONS(): Response { + return new Response(null, { + status: 204, + headers: CORS_HEADERS, + }); +} + +export async function POST(request: Request): Promise { + const result = await ingestOtelTraces(request); + if (result.status === 204) { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + return Response.json(result.body ?? { error: "OTEL ingest failed" }, { + status: result.status, + headers: CORS_HEADERS, + }); +} diff --git a/apps/landing/src/lib/otelIngest.test.ts b/apps/landing/src/lib/otelIngest.test.ts new file mode 100644 index 00000000000..2cd42bd789b --- /dev/null +++ b/apps/landing/src/lib/otelIngest.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { __resetOtelIngestForTests, ingestOtelTraces } from "./otelIngest"; + +const makePayload = () => ({ + resourceSpans: [ + { + resource: { + attributes: [ + { key: "service.name", value: { stringValue: "marcode-web" } }, + { key: "project.name", value: { stringValue: "MarCode" } }, + { key: "auth.token", value: { stringValue: "secret" } }, + ], + }, + scopeSpans: [ + { + scope: { name: "test", attributes: [] }, + spans: [ + { + traceId: "1".repeat(32), + spanId: "2".repeat(16), + name: "marcode.ui.composer.submit", + kind: 1, + startTimeUnixNano: "1", + endTimeUnixNano: "2", + attributes: [ + { key: "provider", value: { stringValue: "codex" } }, + { key: "prompt.text", value: { stringValue: "do not forward" } }, + ], + events: [ + { + name: "event", + timeUnixNano: "1", + attributes: [{ key: "stdout", value: { stringValue: "nope" } }], + }, + ], + links: [], + status: { code: "STATUS_CODE_OK" }, + }, + ], + }, + ], + }, + ], +}); + +function request(body: unknown, headers?: Record) { + return new Request("https://marcode.dev/api/otel/traces", { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +afterEach(() => { + __resetOtelIngestForTests(); + vi.restoreAllMocks(); +}); + +describe("otel ingest", () => { + it("sanitizes and forwards valid OTLP payloads", async () => { + const forwarded: unknown[] = []; + const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + forwarded.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 204 }); + }) as unknown as typeof fetch; + + const result = await ingestOtelTraces(request(makePayload()), { + collectorUrl: "https://collector.example/v1/traces", + fetch: fetchMock, + }); + + expect(result.status).toBe(204); + expect(forwarded).toHaveLength(1); + const payload = forwarded[0] as ReturnType; + const attrs = payload.resourceSpans[0]!.resource.attributes; + expect(attrs).toContainEqual({ key: "service.name", value: { stringValue: "marcode" } }); + expect(attrs).toContainEqual({ + key: "marcode.original_service_name", + value: { stringValue: "marcode-web" }, + }); + expect(attrs.some((attr) => attr.key === "auth.token")).toBe(false); + const spanAttrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes; + expect(spanAttrs).toEqual([{ key: "provider", value: { stringValue: "codex" } }]); + expect(payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.events![0]!.attributes).toEqual([]); + }); + + it("marks Genesis users from verified Jira email and bypasses rate limits", async () => { + const forwarded: unknown[] = []; + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url) === "https://api.atlassian.com/me") { + return Response.json({ email: "dev@gen.tech", account_id: "jira-account" }); + } + forwarded.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 204 }); + }) as unknown as typeof fetch; + + for (let index = 0; index < 3; index++) { + const result = await ingestOtelTraces( + request(makePayload(), { "x-marcode-jira-access-token": "jira-token" }), + { + collectorUrl: "https://collector.example/v1/traces", + fetch: fetchMock, + rateLimitPerMinute: 1, + clientKey: "same-client", + }, + ); + expect(result.status).toBe(204); + } + + const payload = forwarded[0] as ReturnType; + expect(payload.resourceSpans[0]!.resource.attributes).toContainEqual({ + key: "analytics.user.is_genesis", + value: { boolValue: true }, + }); + }); + + it("rate limits non-Genesis users", async () => { + const fetchMock = vi.fn( + async () => new Response(null, { status: 204 }), + ) as unknown as typeof fetch; + + const first = await ingestOtelTraces(request(makePayload()), { + collectorUrl: "https://collector.example/v1/traces", + fetch: fetchMock, + rateLimitPerMinute: 1, + clientKey: "same-client", + }); + const second = await ingestOtelTraces(request(makePayload()), { + collectorUrl: "https://collector.example/v1/traces", + fetch: fetchMock, + rateLimitPerMinute: 1, + clientKey: "same-client", + }); + + expect(first.status).toBe(204); + expect(second.status).toBe(429); + }); + + it("rejects invalid and oversized payloads", async () => { + const invalid = await ingestOtelTraces(request({ nope: true }), { + collectorUrl: "https://collector.example/v1/traces", + }); + const oversized = await ingestOtelTraces(request(makePayload()), { + collectorUrl: "https://collector.example/v1/traces", + maxBodyBytes: 10, + }); + + expect(invalid.status).toBe(400); + expect(oversized.status).toBe(413); + }); + + it("returns 502 when collector forwarding fails", async () => { + const fetchMock = vi.fn( + async () => new Response("bad", { status: 500 }), + ) as unknown as typeof fetch; + + const result = await ingestOtelTraces(request(makePayload()), { + collectorUrl: "https://collector.example/v1/traces", + fetch: fetchMock, + }); + + expect(result.status).toBe(502); + }); +}); diff --git a/apps/landing/src/lib/otelIngest.ts b/apps/landing/src/lib/otelIngest.ts new file mode 100644 index 00000000000..9608932891f --- /dev/null +++ b/apps/landing/src/lib/otelIngest.ts @@ -0,0 +1,437 @@ +import packageJson from "../../package.json" with { type: "json" }; + +const ATLASSIAN_ME_URL = "https://api.atlassian.com/me"; +const DEFAULT_MAX_BODY_BYTES = 256 * 1024; +const DEFAULT_RATE_LIMIT_PER_MINUTE = 60; +const DEFAULT_RATE_LIMIT_BYTES_PER_HOUR = 10 * 1024 * 1024; +const DEFAULT_GENESIS_DOMAINS = ["gen.tech", "obrio.co"] as const; +const JIRA_ACCESS_TOKEN_HEADER = "x-marcode-jira-access-token"; +const UNSAFE_ATTRIBUTE_KEY_PARTS = [ + "token", + "secret", + "password", + "authorization", + "cookie", + "prompt", + "content", + "raw", + "stdout", + "stderr", +] as const; + +type OtlpAnyValue = + | { stringValue: string } + | { boolValue: boolean } + | { intValue: string | number } + | { doubleValue: number } + | { arrayValue: { values: OtlpAnyValue[] } } + | { kvlistValue: { values: OtlpKeyValue[] } } + | { bytesValue: string }; + +interface OtlpKeyValue { + key: string; + value: OtlpAnyValue; +} + +interface OtlpResourceSpan { + resource?: { + attributes?: OtlpKeyValue[]; + [key: string]: unknown; + }; + scopeSpans?: Array<{ + scope?: { + attributes?: OtlpKeyValue[]; + [key: string]: unknown; + }; + spans?: Array<{ + attributes?: OtlpKeyValue[]; + events?: Array<{ attributes?: OtlpKeyValue[]; [key: string]: unknown }>; + links?: Array<{ attributes?: OtlpKeyValue[]; [key: string]: unknown }>; + [key: string]: unknown; + }>; + [key: string]: unknown; + }>; + [key: string]: unknown; +} + +interface OtlpTracePayload { + resourceSpans: OtlpResourceSpan[]; +} + +interface RateLimitBucket { + minuteStartedAt: number; + minuteCount: number; + hourStartedAt: number; + hourBytes: number; +} + +interface JiraVerification { + isGenesis: boolean; + accountIdHash?: string; + expiresAt: number; +} + +export interface OtelIngestResult { + status: 204 | 400 | 413 | 429 | 502 | 503; + body?: Record; + forwardedPayload?: OtlpTracePayload; +} + +export interface OTelIngestOptions { + now?: () => number; + fetch?: typeof fetch; + collectorUrl?: string; + collectorHeaders?: string; + maxBodyBytes?: number; + rateLimitPerMinute?: number; + rateLimitBytesPerHour?: number; + genesisDomains?: ReadonlyArray; + clientKey?: string; +} + +const rateLimitBuckets = new Map(); +const jiraVerificationCache = new Map(); + +function parsePositiveInt(raw: string | undefined, fallback: number): number { + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseCollectorHeaders(raw: string | undefined): Headers { + const headers = new Headers({ "Content-Type": "application/json" }); + if (!raw) return headers; + + for (const part of raw.split(",")) { + const separator = part.indexOf("="); + if (separator <= 0) continue; + const key = part.slice(0, separator).trim(); + const value = part.slice(separator + 1).trim(); + if (key && value) headers.set(key, value); + } + + return headers; +} + +function parseGenesisDomains(raw: string | undefined): ReadonlyArray { + const domains = raw + ?.split(",") + .map((entry) => entry.trim().toLowerCase().replace(/^@/, "")) + .filter(Boolean); + return domains && domains.length > 0 ? domains : DEFAULT_GENESIS_DOMAINS; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOtlpTracePayload(value: unknown): value is OtlpTracePayload { + return ( + isRecord(value) && + Array.isArray(value.resourceSpans) && + value.resourceSpans.every((resourceSpan) => isRecord(resourceSpan)) + ); +} + +function shouldDropAttribute(key: string): boolean { + const normalized = key.toLowerCase(); + return UNSAFE_ATTRIBUTE_KEY_PARTS.some((part) => normalized.includes(part)); +} + +function sanitizeAttributes(attributes: OtlpKeyValue[] | undefined): OtlpKeyValue[] { + if (!Array.isArray(attributes)) return []; + return attributes.filter((attribute) => { + if (!isRecord(attribute) || typeof attribute.key !== "string") return false; + return !shouldDropAttribute(attribute.key); + }); +} + +function stringValue(value: string): OtlpAnyValue { + return { stringValue: value }; +} + +function boolValue(value: boolean): OtlpAnyValue { + return { boolValue: value }; +} + +function upsertAttribute(attributes: OtlpKeyValue[], key: string, value: OtlpAnyValue): void { + const existing = attributes.find((attribute) => attribute.key === key); + if (existing) { + existing.value = value; + } else { + attributes.push({ key, value }); + } +} + +function getStringAttribute(attributes: OtlpKeyValue[], key: string): string | undefined { + const value = attributes.find((attribute) => attribute.key === key)?.value; + return value && "stringValue" in value ? value.stringValue : undefined; +} + +function sanitizePayload( + payload: OtlpTracePayload, + identity: { isGenesis: boolean; jiraAccountIdHash?: string }, +): OtlpTracePayload { + return { + ...payload, + resourceSpans: payload.resourceSpans.map((resourceSpan) => { + const resource = isRecord(resourceSpan.resource) ? { ...resourceSpan.resource } : {}; + const resourceAttributes = sanitizeAttributes(resource.attributes); + const originalServiceName = getStringAttribute(resourceAttributes, "service.name"); + + if (originalServiceName && originalServiceName !== "marcode") { + upsertAttribute( + resourceAttributes, + "marcode.original_service_name", + stringValue(originalServiceName), + ); + } + upsertAttribute(resourceAttributes, "service.name", stringValue("marcode")); + upsertAttribute(resourceAttributes, "marcode.ingest", stringValue("landing")); + upsertAttribute( + resourceAttributes, + "marcode.ingest.version", + stringValue(packageJson.version), + ); + upsertAttribute( + resourceAttributes, + "analytics.user.is_genesis", + boolValue(identity.isGenesis), + ); + if (identity.jiraAccountIdHash) { + upsertAttribute( + resourceAttributes, + "analytics.user.jira.account_id_hash", + stringValue(identity.jiraAccountIdHash), + ); + } + resource.attributes = resourceAttributes; + + const sanitizedResourceSpan: OtlpResourceSpan = { + ...resourceSpan, + resource, + }; + if (Array.isArray(resourceSpan.scopeSpans)) { + sanitizedResourceSpan.scopeSpans = resourceSpan.scopeSpans.map((scopeSpan) => { + const sanitizedScopeSpan: NonNullable[number] = { + ...scopeSpan, + }; + if (isRecord(scopeSpan.scope)) { + sanitizedScopeSpan.scope = { + ...scopeSpan.scope, + attributes: sanitizeAttributes(scopeSpan.scope.attributes), + }; + } + if (Array.isArray(scopeSpan.spans)) { + sanitizedScopeSpan.spans = scopeSpan.spans.map((span) => { + const sanitizedSpan: NonNullable< + NonNullable[number]["spans"] + >[number] = { + ...span, + attributes: sanitizeAttributes(span.attributes), + }; + if (Array.isArray(span.events)) { + sanitizedSpan.events = span.events.map((event) => ({ + ...event, + attributes: sanitizeAttributes(event.attributes), + })); + } + if (Array.isArray(span.links)) { + sanitizedSpan.links = span.links.map((link) => ({ + ...link, + attributes: sanitizeAttributes(link.attributes), + })); + } + return sanitizedSpan; + }); + } + return sanitizedScopeSpan; + }); + } + return sanitizedResourceSpan; + }), + }; +} + +async function sha256Hex(value: string): Promise { + const data = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function emailMatchesGenesisDomain(email: string, domains: ReadonlyArray): boolean { + const normalized = email.trim().toLowerCase(); + return domains.some((domain) => normalized.endsWith(`@${domain}`)); +} + +async function verifyJiraToken(input: { + token: string | null; + now: number; + fetchImpl: typeof fetch; + genesisDomains: ReadonlyArray; +}): Promise<{ isGenesis: boolean; jiraAccountIdHash?: string }> { + if (!input.token) return { isGenesis: false }; + + const tokenHash = await sha256Hex(input.token); + const cached = jiraVerificationCache.get(tokenHash); + if (cached && cached.expiresAt > input.now) { + return { + isGenesis: cached.isGenesis, + ...(cached.accountIdHash ? { jiraAccountIdHash: cached.accountIdHash } : {}), + }; + } + + try { + const response = await input.fetchImpl(ATLASSIAN_ME_URL, { + headers: { Authorization: `Bearer ${input.token}`, Accept: "application/json" }, + }); + if (!response.ok) return { isGenesis: false }; + const data = (await response.json()) as { + email?: string; + emailAddress?: string; + account_id?: string; + accountId?: string; + }; + const email = data.email ?? data.emailAddress ?? ""; + const accountId = data.account_id ?? data.accountId; + const accountIdHash = accountId ? await sha256Hex(accountId) : undefined; + const verification: JiraVerification = { + isGenesis: emailMatchesGenesisDomain(email, input.genesisDomains), + ...(accountIdHash ? { accountIdHash } : {}), + expiresAt: input.now + 15 * 60 * 1000, + }; + jiraVerificationCache.set(tokenHash, verification); + return { + isGenesis: verification.isGenesis, + ...(verification.accountIdHash ? { jiraAccountIdHash: verification.accountIdHash } : {}), + }; + } catch { + return { isGenesis: false }; + } +} + +function checkRateLimit(input: { + key: string; + bytes: number; + now: number; + perMinute: number; + bytesPerHour: number; +}): boolean { + const current = rateLimitBuckets.get(input.key); + const bucket: RateLimitBucket = + current ?? + ({ + minuteStartedAt: input.now, + minuteCount: 0, + hourStartedAt: input.now, + hourBytes: 0, + } satisfies RateLimitBucket); + + if (input.now - bucket.minuteStartedAt >= 60_000) { + bucket.minuteStartedAt = input.now; + bucket.minuteCount = 0; + } + if (input.now - bucket.hourStartedAt >= 60 * 60_000) { + bucket.hourStartedAt = input.now; + bucket.hourBytes = 0; + } + + bucket.minuteCount += 1; + bucket.hourBytes += input.bytes; + rateLimitBuckets.set(input.key, bucket); + + return bucket.minuteCount <= input.perMinute && bucket.hourBytes <= input.bytesPerHour; +} + +function getClientKey(request: Request, explicit: string | undefined): string { + if (explicit) return explicit; + return ( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip")?.trim() || + "unknown" + ); +} + +export async function ingestOtelTraces( + request: Request, + options: OTelIngestOptions = {}, +): Promise { + const now = options.now?.() ?? Date.now(); + const fetchImpl = options.fetch ?? fetch; + const collectorUrl = options.collectorUrl ?? process.env.MARCODE_OTEL_COLLECTOR_TRACES_URL; + const maxBodyBytes = + options.maxBodyBytes ?? + parsePositiveInt(process.env.MARCODE_OTEL_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES); + const rateLimitPerMinute = + options.rateLimitPerMinute ?? + parsePositiveInt(process.env.MARCODE_OTEL_RATE_LIMIT_PER_MINUTE, DEFAULT_RATE_LIMIT_PER_MINUTE); + const rateLimitBytesPerHour = + options.rateLimitBytesPerHour ?? + parsePositiveInt( + process.env.MARCODE_OTEL_RATE_LIMIT_BYTES_PER_HOUR, + DEFAULT_RATE_LIMIT_BYTES_PER_HOUR, + ); + const genesisDomains = + options.genesisDomains ?? parseGenesisDomains(process.env.MARCODE_OTEL_GENESIS_DOMAINS); + + if (!collectorUrl) { + return { status: 503, body: { error: "OTEL collector is not configured" } }; + } + + const body = await request.text(); + const bodyBytes = new TextEncoder().encode(body).byteLength; + if (bodyBytes > maxBodyBytes) { + return { status: 413, body: { error: "OTEL payload is too large" } }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return { status: 400, body: { error: "Invalid JSON body" } }; + } + if (!isOtlpTracePayload(parsed)) { + return { status: 400, body: { error: "Invalid OTLP trace payload" } }; + } + + const jiraToken = request.headers.get(JIRA_ACCESS_TOKEN_HEADER); + const identity = await verifyJiraToken({ + token: jiraToken, + now, + fetchImpl, + genesisDomains, + }); + + if (!identity.isGenesis) { + const allowed = checkRateLimit({ + key: getClientKey(request, options.clientKey), + bytes: bodyBytes, + now, + perMinute: rateLimitPerMinute, + bytesPerHour: rateLimitBytesPerHour, + }); + if (!allowed) { + return { status: 429, body: { error: "Rate limit exceeded" } }; + } + } + + const forwardedPayload = sanitizePayload(parsed, identity); + const collectorResponse = await fetchImpl(collectorUrl, { + method: "POST", + headers: parseCollectorHeaders( + options.collectorHeaders ?? process.env.MARCODE_OTEL_COLLECTOR_HEADERS, + ), + body: JSON.stringify(forwardedPayload), + }); + + if (!collectorResponse.ok) { + return { status: 502, body: { error: "Failed to forward OTEL payload" }, forwardedPayload }; + } + + return { status: 204, forwardedPayload }; +} + +export function __resetOtelIngestForTests(): void { + rateLimitBuckets.clear(); + jiraVerificationCache.clear(); +} diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index 3bde6e981a0..4a0b0678786 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -19,6 +19,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "marcode-server", + productAnalyticsTracesUrl: undefined, } as const; const openBootstrapFd = Effect.fn(function* (payload: Record) { diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index bb961be663c..ba98c980727 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -64,6 +64,7 @@ const makeCliTestServerConfig = (baseDir: string) => otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "marcode-server", + productAnalyticsTracesUrl: undefined, mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index dd0fcdd4247..cce9cb1222e 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -152,6 +152,10 @@ const EnvServerConfig = Config.all({ otlpServiceName: Config.string("MARCODE_OTLP_SERVICE_NAME").pipe( Config.withDefault("marcode-server"), ), + productAnalyticsTracesUrl: Config.string("MARCODE_PRODUCT_ANALYTICS_TRACES_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), mode: Config.schema(RuntimeMode, "MARCODE_MODE").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -361,6 +365,11 @@ export const resolveServerConfig = ( persistedObservabilitySettings.otlpMetricsUrl, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, + productAnalyticsTracesUrl: + env.productAnalyticsTracesUrl ?? + (env.jiraTokenProxyUrl + ? `${env.jiraTokenProxyUrl.replace(/\/+$/, "")}/api/otel/traces` + : undefined), mode, port, cwd, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 17952dbd4b0..330ae57ec77 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -54,6 +54,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly otlpMetricsUrl: string | undefined; readonly otlpExportIntervalMs: number; readonly otlpServiceName: string; + readonly productAnalyticsTracesUrl: string | undefined; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -156,6 +157,7 @@ export class ServerConfig extends Context.Service 0 ? localName : undefined; +} + function toStatusPr(pr: PullRequestInfo): { number: number; title: string; @@ -489,6 +497,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const serverSettingsService = yield* ServerSettingsService; const jiraContextCollector = yield* JiraContextCollector; + const analytics = Option.getOrElse(yield* Effect.serviceOption(AnalyticsService), () => ({ + record: () => Effect.void, + flush: Effect.void, + })); + + const recordGitAnalytics = (event: string, properties: Record) => + analytics.record(event, properties); const resolveJiraTickets = ( threadId: ThreadId | undefined, @@ -1269,6 +1284,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); const detectedHostProvider = yield* detectHostProvider(cwd); const prOrMr = detectedHostProvider === "gitlab" ? "MR" : "PR"; + const changeRequestKind = detectedHostProvider === "gitlab" ? "mr" : "pr"; + const repositoryName = repositoryLocalName(headContext.originRepositoryNameWithOwner); yield* emit({ kind: "phase_started", phase: "pr", @@ -1293,6 +1310,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { label: `Creating ${prOrMr}...`, }); const originRepo = headContext.originRepositoryNameWithOwner; + yield* recordGitAnalytics("marcode.git.pr_mr.create_requested", { + "git.host.provider": detectedHostProvider ?? "unknown", + "git.change_request.kind": changeRequestKind, + "git.branch": headContext.headBranch, + ...(repositoryName ? { "repository.name": repositoryName } : {}), + }); yield* gitHostCli .createPullRequest({ cwd, @@ -1308,9 +1331,27 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { schedule: Schedule.exponential(PR_CREATE_RETRY_BASE_DELAY, 2), while: isBranchNotReadyError, }), + Effect.tapError(() => + recordGitAnalytics("marcode.git.pr_mr.create_completed", { + "git.host.provider": detectedHostProvider ?? "unknown", + "git.change_request.kind": changeRequestKind, + "git.branch": headContext.headBranch, + outcome: "error", + has_url: false, + ...(repositoryName ? { "repository.name": repositoryName } : {}), + }), + ), ); const created = yield* findOpenPr(cwd, headContext); + yield* recordGitAnalytics("marcode.git.pr_mr.create_completed", { + "git.host.provider": detectedHostProvider ?? "unknown", + "git.change_request.kind": changeRequestKind, + "git.branch": headContext.headBranch, + outcome: "created", + has_url: created?.url ? true : false, + ...(repositoryName ? { "repository.name": repositoryName } : {}), + }); if (!created) { return { status: "created" as const, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7b29ee6bb09..1fe5c8f77ee 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -24,10 +24,19 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { respondToAuthError } from "./auth/http.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { JiraTokenService } from "./jira/Services/JiraTokenService.ts"; +import { getTelemetryIdentifier } from "./telemetry/Identify.ts"; +import { + JIRA_ACCESS_TOKEN_HEADER, + productAnalyticsUrlFromConfig, + productAttributesToOtlp, + shouldAttachJiraProof, +} from "./telemetry/OtlpProduct.ts"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const FALLBACK_PROJECT_FAVICON_SVG = ``; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; +const MAX_BROWSER_OTLP_TRACE_BYTES = 256 * 1024; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); export const browserApiCorsLayer = HttpRouter.cors({ @@ -74,12 +83,110 @@ class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecor readonly bodyJson: OtlpTracer.TraceData; }> {} +class ProductAnalyticsTraceExportError extends Data.TaggedError( + "ProductAnalyticsTraceExportError", +)<{ + readonly cause: unknown; +}> {} + +function readContentLength(request: HttpServerRequest.HttpServerRequest): number | undefined { + const headers = (request as unknown as { readonly headers?: Record }).headers; + const raw = headers?.["content-length"]; + if (!raw) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function appendResourceAttributes( + bodyJson: OtlpTracer.TraceData, + attributes: Readonly>, +): OtlpTracer.TraceData { + const otlpAttributes = productAttributesToOtlp(attributes); + if (otlpAttributes.length === 0) return bodyJson; + + return { + ...bodyJson, + resourceSpans: bodyJson.resourceSpans.map((resourceSpan) => ({ + ...resourceSpan, + resource: { + ...resourceSpan.resource, + attributes: [...(resourceSpan.resource?.attributes ?? []), ...otlpAttributes], + }, + })), + }; +} + +const getJiraProofHeader = (targetUrl: string) => + Effect.gen(function* () { + const config = yield* ServerConfig; + if (!shouldAttachJiraProof({ targetUrl, jiraTokenProxyUrl: config.jiraTokenProxyUrl })) { + return {}; + } + const tokenServiceOption = yield* Effect.serviceOption(JiraTokenService); + const tokenService = Option.getOrUndefined(tokenServiceOption); + const accessToken = tokenService + ? yield* tokenService.getValidAccessToken.pipe(Effect.catch(() => Effect.succeed(null))) + : null; + return accessToken ? { [JIRA_ACCESS_TOKEN_HEADER]: accessToken } : {}; + }); + +const forwardProductAnalyticsTraces = (bodyJson: OtlpTracer.TraceData) => + Effect.gen(function* () { + const config = yield* ServerConfig; + const productAnalyticsUrl = productAnalyticsUrlFromConfig(config); + if (!productAnalyticsUrl) return; + + const identifier = yield* getTelemetryIdentifier; + const proofHeader = yield* getJiraProofHeader(productAnalyticsUrl); + const enrichedBodyJson = appendResourceAttributes(bodyJson, { + ...(identifier + ? { + "analytics.user.id": identifier.id, + "analytics.user.source": identifier.source, + } + : {}), + "analytics.user.is_genesis": false, + "app.mode": config.mode, + platform: process.platform, + arch: process.arch, + }); + + yield* Effect.tryPromise({ + try: async () => { + const response = await fetch(productAnalyticsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...proofHeader, + }, + body: JSON.stringify(enrichedBodyJson), + }); + if (!response.ok) { + throw new Error(`Product analytics export failed with status ${response.status}`); + } + }, + catch: (cause) => new ProductAnalyticsTraceExportError({ cause }), + }).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to export product analytics traces", { + cause, + productAnalyticsUrl, + }), + ), + Effect.catch(() => Effect.void), + ); + }); + export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, Effect.gen(function* () { yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; + const contentLength = readContentLength(request); + if (contentLength !== undefined && contentLength > MAX_BROWSER_OTLP_TRACE_BYTES) { + return HttpServerResponse.text("Trace payload too large.", { status: 413 }); + } const config = yield* ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; const browserTraceCollector = yield* BrowserTraceCollector; @@ -99,6 +206,8 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( ), ); + yield* forwardProductAnalyticsTraces(bodyJson); + if (otlpTracesUrl === undefined) { return HttpServerResponse.empty({ status: 204 }); } diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 67650322a1b..fbecb75dd8b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,4 +1,5 @@ import type { + ModelSelection, OrchestrationEvent, OrchestrationReadModel, ProjectId, @@ -43,6 +44,8 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../Services/OrchestrationEngine.ts"; +import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { hashTelemetryIdentifier } from "../../telemetry/Identify.ts"; interface CommandEnvelope { command: OrchestrationCommand; @@ -70,18 +73,75 @@ function commandToAggregateRef(command: OrchestrationCommand): { } } +function modelFamily(modelSelection: ModelSelection | null | undefined): string | undefined { + return modelSelection?.model; +} + const makeOrchestrationEngine = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const eventStore = yield* OrchestrationEventStore; const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const analytics = Option.getOrElse(yield* Effect.serviceOption(AnalyticsService), () => ({ + record: () => Effect.void, + flush: Effect.void, + })); let readModel = createEmptyReadModel(new Date().toISOString()); const commandQueue = yield* Queue.unbounded(); const eventPubSub = yield* PubSub.unbounded(); + const recordAnalytics = (event: string, properties: Record) => + analytics.record(event, properties); + + const recordProductEvent = (event: OrchestrationEvent) => + Effect.gen(function* () { + switch (event.type) { + case "project.created": { + const projectName = event.payload.title.trim(); + yield* recordAnalytics("marcode.project.opened", { + "project.name": projectName, + "project.cwd_hash": hashTelemetryIdentifier(event.payload.workspaceRoot), + }); + return; + } + + case "thread.created": { + const project = readModel.projects.find((entry) => entry.id === event.payload.projectId); + yield* recordAnalytics("marcode.thread.created", { + "thread.id_hash": hashTelemetryIdentifier(event.payload.threadId), + ...(project ? { "project.name": project.title } : {}), + "provider.default": event.payload.modelSelection.provider, + }); + return; + } + + case "thread.message-sent": { + if (event.payload.role !== "user") return; + const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId); + const project = thread + ? readModel.projects.find((entry) => entry.id === thread.projectId) + : undefined; + const modelSelection = thread?.modelSelection; + yield* recordAnalytics("marcode.message.user.sent", { + "thread.id_hash": hashTelemetryIdentifier(event.payload.threadId), + ...(project ? { "project.name": project.title } : {}), + ...(modelSelection + ? { + provider: modelSelection.provider, + "model.family": modelFamily(modelSelection), + } + : {}), + "attachment.count": event.payload.attachments?.length ?? 0, + "jira.context.count": 0, + }); + return; + } + } + }); + const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => { const dispatchStartSequence = readModel.snapshotSequence; const processingStartedAtMs = Date.now(); @@ -187,6 +247,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { readModel = committedCommand.nextReadModel; for (const [index, event] of committedCommand.committedEvents.entries()) { yield* PubSub.publish(eventPubSub, event); + yield* recordProductEvent(event).pipe(Effect.catch(() => Effect.void)); if (index === 0) { yield* Metric.update( Metric.withAttributes( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index ad89bcb699c..6894ed4c97e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -43,6 +43,8 @@ import { } from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { hashTelemetryIdentifier } from "../../telemetry/Identify.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; @@ -144,6 +146,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { const serverSettings = yield* ServerSettingsService; + const analyticsOption = yield* Effect.serviceOption(AnalyticsService); + const analytics = Option.getOrElse(analyticsOption, () => ({ + record: () => Effect.void, + flush: Effect.void, + })); const canonicalEventLogger = options?.canonicalEventLogger ?? (options?.canonicalEventLogPath !== undefined @@ -187,11 +194,77 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const providers = yield* registry.listProviders(); const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); + const hashId = (id: unknown) => + hashTelemetryIdentifier(String(id)).pipe(Effect.orElseSucceed(() => String(id))); + + const recordRuntimeAnalytics = (event: ProviderRuntimeEvent): Effect.Effect => + Effect.gen(function* () { + const threadIdHash = yield* hashId(event.threadId); + if ( + (event.type === "item.started" || event.type === "item.updated") && + "itemType" in event.payload && + typeof event.payload.itemType === "string" && + event.payload.itemType.includes("tool_call") + ) { + yield* analytics.record("marcode.tool.call.started", { + provider: event.provider, + "thread.id_hash": threadIdHash, + "tool.kind": event.payload.itemType, + "tool.name_normalized": + "title" in event.payload && typeof event.payload.title === "string" + ? event.payload.title.toLowerCase() + : event.payload.itemType, + }); + } + if ( + event.type === "item.completed" && + "itemType" in event.payload && + typeof event.payload.itemType === "string" && + event.payload.itemType.includes("tool_call") + ) { + yield* analytics.record("marcode.tool.call.completed", { + provider: event.provider, + "thread.id_hash": threadIdHash, + "tool.kind": event.payload.itemType, + "tool.name_normalized": + "title" in event.payload && typeof event.payload.title === "string" + ? event.payload.title.toLowerCase() + : event.payload.itemType, + outcome: "success", + }); + } + if (event.type === "request.opened") { + yield* analytics.record("marcode.approval.requested", { + provider: event.provider, + "thread.id_hash": threadIdHash, + "request.type": event.payload.requestType, + }); + } + if (event.type === "request.resolved") { + yield* analytics.record("marcode.approval.resolved", { + provider: event.provider, + "thread.id_hash": threadIdHash, + "request.type": event.payload.requestType, + decision: event.payload.decision, + }); + } + if (event.type === "turn.completed" || event.type === "turn.aborted") { + yield* analytics.record("marcode.provider.turn.completed", { + provider: event.provider, + "thread.id_hash": threadIdHash, + outcome: event.type === "turn.completed" ? "success" : "failure", + }); + } + }).pipe(Effect.ignoreCause({ log: true })); + const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => increment(providerRuntimeEventsTotal, { provider: event.provider, eventType: event.type, - }).pipe(Effect.andThen(publishRuntimeEvent(event))); + }).pipe( + Effect.andThen(recordRuntimeAnalytics(event)), + Effect.andThen(publishRuntimeEvent(event)), + ); yield* Effect.forEach(adapters, (adapter) => Stream.runForEach(adapter.streamEvents, processRuntimeEvent).pipe(Effect.forkScoped), @@ -405,6 +478,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* upsertSessionBinding(session, threadId, { modelSelection: input.modelSelection, }); + yield* analytics.record("marcode.provider.session.started", { + provider: adapter.provider, + "thread.id_hash": yield* hashId(threadId), + runtime_mode: input.runtimeMode, + ...(input.modelSelection?.model ? { "model.family": input.modelSelection.model } : {}), + }); return session; }).pipe( @@ -456,6 +535,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), }); const turn = yield* routed.adapter.sendTurn(input); + yield* analytics.record("marcode.provider.turn.sent", { + provider: routed.adapter.provider, + "thread.id_hash": yield* hashId(input.threadId), + interaction_mode: input.interactionMode, + "attachment.count": input.attachments.length, + ...(input.modelSelection?.model ? { "model.family": input.modelSelection.model } : {}), + }); yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b6f8aa372b8..2cd2540f205 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -107,7 +107,7 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; import { JiraApiClient } from "./jira/Services/JiraApiClient.ts"; -import { JiraTokenService } from "./jira/Services/JiraTokenService.ts"; +import { JiraTokenService, type JiraTokenServiceShape } from "./jira/Services/JiraTokenService.ts"; import { ProviderService } from "./provider/Services/ProviderService.ts"; const defaultProjectId = ProjectId.make("project-default"); @@ -344,6 +344,7 @@ const buildAppUnderTest = (options?: { serverRuntimeStartup?: Partial; serverEnvironment?: Partial; repositoryIdentityResolver?: Partial; + jiraTokenService?: Partial; }; }) => Effect.gen(function* () { @@ -365,6 +366,7 @@ const buildAppUnderTest = (options?: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "marcode-server", + productAnalyticsTracesUrl: undefined, mode: "desktop", port: 0, host: "127.0.0.1", @@ -572,6 +574,7 @@ const buildAppUnderTest = (options?: { clearTokens: Effect.void, getValidAccessToken: Effect.die("unused"), streamChanges: Stream.empty, + ...options?.layers?.jiraTokenService, }), ), Layer.provide( @@ -1733,6 +1736,88 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "forwards browser OTLP traces to product analytics with Jira proof only for trusted origin", + () => + Effect.gen(function* () { + const productRequests: Array<{ + readonly body: string; + readonly jiraToken: string | null; + }> = []; + const payload = yield* makeBrowserOtlpPayload("product.client.test"); + const collector = yield* Effect.acquireRelease( + Effect.promise(async () => { + const NodeHttp = await import("node:http"); + return await new Promise<{ + readonly close: () => Promise; + readonly url: string; + }>((resolve, reject) => { + const server = NodeHttp.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => { + productRequests.push({ + body: Buffer.concat(chunks).toString("utf8"), + jiraToken: + typeof request.headers["x-marcode-jira-access-token"] === "string" + ? request.headers["x-marcode-jira-access-token"] + : null, + }); + response.statusCode = 204; + response.end(); + }); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Expected TCP collector address")); + return; + } + resolve({ + url: `http://127.0.0.1:${address.port}/api/otel/traces`, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((error) => (error ? rejectClose(error) : resolveClose())); + }), + }); + }); + }); + }), + ({ close }) => Effect.promise(close), + ); + + yield* buildAppUnderTest({ + config: { + productAnalyticsTracesUrl: collector.url, + jiraTokenProxyUrl: new URL("/", collector.url).origin, + }, + layers: { + jiraTokenService: { + getValidAccessToken: Effect.succeed("jira-proof-token"), + }, + }, + }); + + const response = yield* HttpClient.post("/api/observability/v1/traces", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + "content-type": "application/json", + }, + body: HttpBody.text(JSON.stringify(payload), "application/json"), + }); + + assert.equal(response.status, 204); + assert.equal(productRequests.length, 1); + assert.equal(productRequests[0]?.jiraToken, "jira-proof-token"); + const forwarded = JSON.parse(productRequests[0]!.body) as typeof payload; + const attributes = forwarded.resourceSpans[0]?.resource?.attributes ?? []; + assertTrue(attributes.some((attribute) => attribute.key === "analytics.user.is_genesis")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "stores browser OTLP trace exports locally when no upstream collector is configured", () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 215efc94108..4a85d98c5a7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -60,7 +60,7 @@ import { jiraCallbackRouteLayer, } from "./jira/oauthRoutes.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; -import { AnalyticsServiceNoopLive } from "./telemetry/Layers/AnalyticsService.ts"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; import { authBearerBootstrapRouteLayer, authBootstrapRouteLayer, @@ -257,7 +257,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), - Layer.provideMerge(AnalyticsServiceNoopLive), + Layer.provideMerge(AnalyticsServiceLayerLive), // Misc. Layer.provideMerge(OpenLive), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index de3a2883c38..dfc0396cbfc 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -140,9 +140,9 @@ export const recordStartupHeartbeat = Effect.gen(function* () { ), ); - yield* analytics.record("server.boot.heartbeat", { - threadCount, - projectCount, + yield* analytics.record("marcode.app.boot", { + "project.count": projectCount, + "thread.count": threadCount, }); }); diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts new file mode 100644 index 00000000000..ae51cb399a6 --- /dev/null +++ b/apps/server/src/telemetry/Identify.test.ts @@ -0,0 +1,50 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { afterEach, vi } from "vitest"; + +import { ServerConfig } from "../config.ts"; +import { getTelemetryIdentifier, hashTelemetryIdentifier } from "./Identify.ts"; + +const identifyTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "marcode-identify-test-", +}).pipe(Layer.provide(NodeServices.layer)); +const testLayer = Layer.mergeAll(identifyTestLayer, NodeServices.layer); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("getTelemetryIdentifier", () => { + it.effect("falls back to a persisted anonymous id", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "marcode-identify-home-" }); + vi.stubEnv("HOME", homeDir); + const config = yield* ServerConfig; + yield* fs.makeDirectory(path.dirname(config.anonymousIdPath), { recursive: true }); + yield* fs.writeFileString(config.anonymousIdPath, "anonymous-id"); + + const identifier = yield* getTelemetryIdentifier; + assert.deepEqual(identifier, { + id: yield* hashTelemetryIdentifier("anonymous-id"), + source: "anonymous", + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("creates an anonymous id when missing", () => + Effect.gen(function* () { + const config = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "marcode-identify-home-" }); + vi.stubEnv("HOME", homeDir); + const identifier = yield* getTelemetryIdentifier; + const persisted = yield* fs.readFileString(config.anonymousIdPath); + + assert.equal(identifier?.source, "anonymous"); + assert.equal(persisted.trim().length > 0, true); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts new file mode 100644 index 00000000000..e346f240057 --- /dev/null +++ b/apps/server/src/telemetry/Identify.ts @@ -0,0 +1,104 @@ +import { Effect, FileSystem, Path, Random, Schema } from "effect"; +import * as Crypto from "node:crypto"; +import { homedir } from "node:os"; + +import { ServerConfig } from "../config.ts"; + +const CodexAuthJsonSchema = Schema.Struct({ + tokens: Schema.Struct({ + account_id: Schema.String, + }), +}); + +const ClaudeJsonSchema = Schema.Struct({ + userID: Schema.String, +}); + +class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export interface TelemetryIdentifier { + readonly id: string; + readonly source: "codex" | "claude" | "anonymous"; +} + +export const hashTelemetryIdentifier = (value: string) => + Effect.try({ + try: () => Crypto.createHash("sha256").update(value).digest("hex"), + catch: (error) => + new IdentifyUserError({ + message: "Failed to hash identifier", + cause: error, + }), + }); + +const getCodexAccountId = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const authJsonPath = path.join(homedir(), ".codex", "auth.json"); + const authJson = yield* Effect.flatMap( + fileSystem.readFileString(authJsonPath), + Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), + ); + return authJson.tokens.account_id; +}); + +const getClaudeUserId = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const claudeJsonPath = path.join(homedir(), ".claude.json"); + const claudeJson = yield* Effect.flatMap( + fileSystem.readFileString(claudeJsonPath), + Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), + ); + return claudeJson.userID; +}); + +const upsertAnonymousId = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { anonymousIdPath } = yield* ServerConfig; + return yield* fileSystem.readFileString(anonymousIdPath).pipe( + Effect.catch(() => + Effect.gen(function* () { + const randomId = yield* Random.nextUUIDv4; + yield* fileSystem.writeFileString(anonymousIdPath, randomId); + return randomId; + }), + ), + ); +}); + +export const getTelemetryIdentifier = Effect.gen(function* () { + const codexAccountId = yield* Effect.result(getCodexAccountId); + if (codexAccountId._tag === "Success") { + return { + id: yield* hashTelemetryIdentifier(codexAccountId.success), + source: "codex" as const, + }; + } + + const claudeUserId = yield* Effect.result(getClaudeUserId); + if (claudeUserId._tag === "Success") { + return { + id: yield* hashTelemetryIdentifier(claudeUserId.success), + source: "claude" as const, + }; + } + + const anonymousId = yield* Effect.result(upsertAnonymousId); + if (anonymousId._tag === "Success") { + return { + id: yield* hashTelemetryIdentifier(anonymousId.success), + source: "anonymous" as const, + }; + } + + return null; +}).pipe( + Effect.tapError((error) => + Effect.logWarning("Failed to get telemetry identifier", { cause: error }), + ), + Effect.orElseSucceed(() => null), +); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 1af0d552ae3..72715d67427 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -1,8 +1,162 @@ -import { Effect, Layer } from "effect"; +import { Data, DateTime, Effect, Layer, Option, Ref } from "effect"; +import { ServerConfig } from "../../config.ts"; +import { JiraTokenService } from "../../jira/Services/JiraTokenService.ts"; import { AnalyticsService } from "../Services/AnalyticsService.ts"; +import { getTelemetryIdentifier } from "../Identify.ts"; +import { + JIRA_ACCESS_TOKEN_HEADER, + makeProductSpanPayload, + productAnalyticsUrlFromConfig, + shouldAttachJiraProof, +} from "../OtlpProduct.ts"; export const AnalyticsServiceNoopLive = Layer.succeed(AnalyticsService, { record: () => Effect.void, flush: Effect.void, }); + +interface BufferedAnalyticsEvent { + readonly event: string; + readonly properties?: Readonly>; + readonly capturedAt: string; +} + +const MAX_BUFFERED_EVENTS = 1_000; +const FLUSH_BATCH_SIZE = 20; + +class ProductAnalyticsExportError extends Data.TaggedError("ProductAnalyticsExportError")<{ + readonly cause: unknown; +}> {} + +const makeAnalyticsService = Effect.gen(function* () { + const config = yield* ServerConfig; + const jiraTokenService = yield* Effect.serviceOption(JiraTokenService); + const identifier = yield* getTelemetryIdentifier; + const bufferRef = yield* Ref.make>([]); + const productAnalyticsUrl = productAnalyticsUrlFromConfig(config); + + const makeBaseAttributes = () => ({ + ...(identifier + ? { + "analytics.user.id": identifier.id, + "analytics.user.source": identifier.source, + } + : {}), + "analytics.user.is_genesis": false, + "app.mode": config.mode, + "app.version": process.env.npm_package_version, + platform: process.platform, + arch: process.arch, + }); + + const getJiraProofHeader = Effect.gen(function* () { + if ( + !productAnalyticsUrl || + !shouldAttachJiraProof({ + targetUrl: productAnalyticsUrl, + jiraTokenProxyUrl: config.jiraTokenProxyUrl, + }) + ) { + return {}; + } + const tokenService = Option.getOrUndefined(jiraTokenService); + const accessToken = tokenService + ? yield* tokenService.getValidAccessToken.pipe(Effect.catch(() => Effect.succeed(null))) + : null; + return accessToken ? { [JIRA_ACCESS_TOKEN_HEADER]: accessToken } : {}; + }); + + const sendBatch = (events: ReadonlyArray) => + Effect.gen(function* () { + if (!productAnalyticsUrl || events.length === 0) return; + const proofHeader = yield* getJiraProofHeader; + yield* Effect.forEach( + events, + (event) => + Effect.tryPromise({ + try: async () => { + const response = await fetch(productAnalyticsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...proofHeader, + }, + body: JSON.stringify( + makeProductSpanPayload({ + event: event.event, + capturedAt: event.capturedAt, + attributes: { + ...makeBaseAttributes(), + ...event.properties, + }, + }), + ), + }); + if (!response.ok) { + throw new Error(`Product analytics export failed with status ${response.status}`); + } + }, + catch: (cause) => new ProductAnalyticsExportError({ cause }), + }), + { discard: true, concurrency: 2 }, + ); + }); + + const flush = Effect.gen(function* () { + while (true) { + const batch = yield* Ref.modify(bufferRef, (current) => { + if (current.length === 0) { + return [[] as ReadonlyArray, current] as const; + } + const nextBatch = current.slice(0, FLUSH_BATCH_SIZE); + const remaining = current.slice(nextBatch.length); + return [nextBatch, remaining] as const; + }); + if (batch.length === 0) return; + yield* sendBatch(batch).pipe( + Effect.catch((cause) => + Effect.all( + [ + Ref.update(bufferRef, (current) => [...batch, ...current]), + Effect.logWarning("Failed to flush product analytics", { cause }), + ], + { discard: true }, + ), + ), + ); + } + }).pipe(Effect.catchCause(() => Effect.void)); + + const record = (event: string, properties?: Record) => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + ...makeBaseAttributes(), + ...properties, + }).pipe(Effect.withSpan(event)); + + const now = yield* DateTime.now; + yield* Ref.update(bufferRef, (current) => { + const appended = [ + ...current, + { + event, + ...(properties ? { properties } : {}), + capturedAt: DateTime.formatIso(now), + }, + ]; + return appended.length > MAX_BUFFERED_EVENTS + ? appended.slice(appended.length - MAX_BUFFERED_EVENTS) + : appended; + }); + }); + + yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { + disableYield: true, + }).pipe(Effect.forkScoped); + yield* Effect.addFinalizer(() => flush); + + return { record, flush }; +}); + +export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService); diff --git a/apps/server/src/telemetry/OtlpProduct.ts b/apps/server/src/telemetry/OtlpProduct.ts new file mode 100644 index 00000000000..1e08c598c80 --- /dev/null +++ b/apps/server/src/telemetry/OtlpProduct.ts @@ -0,0 +1,92 @@ +import packageJson from "../../package.json" with { type: "json" }; + +export const JIRA_ACCESS_TOKEN_HEADER = "x-marcode-jira-access-token"; + +export type ProductAnalyticsAttributes = Readonly>; + +function valueToOtlp(value: unknown): Record | null { + if (value === undefined || value === null) return null; + if (typeof value === "string") return { stringValue: value }; + if (typeof value === "boolean") return { boolValue: value }; + if (typeof value === "number") { + return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value }; + } + if (typeof value === "bigint") return { intValue: value.toString() }; + return { stringValue: String(value) }; +} + +export function productAttributesToOtlp(attributes: ProductAnalyticsAttributes) { + return Object.entries(attributes).flatMap(([key, value]) => { + const otlpValue = valueToOtlp(value); + return otlpValue ? [{ key, value: otlpValue }] : []; + }); +} + +export function makeProductSpanPayload(input: { + readonly event: string; + readonly attributes: ProductAnalyticsAttributes; + readonly capturedAt: string; +}) { + const now = BigInt(new Date(input.capturedAt).getTime()) * 1_000_000n; + return { + resourceSpans: [ + { + resource: { + attributes: productAttributesToOtlp({ + "service.name": "marcode", + "service.runtime": "marcode-server", + "service.version": packageJson.version, + }), + }, + scopeSpans: [ + { + scope: { + name: "marcode.product-analytics", + version: packageJson.version, + attributes: [], + }, + spans: [ + { + traceId: crypto.randomUUID().replaceAll("-", ""), + spanId: crypto.randomUUID().replaceAll("-", "").slice(0, 16), + name: input.event, + kind: 1, + startTimeUnixNano: String(now), + endTimeUnixNano: String(now + 1_000_000n), + attributes: productAttributesToOtlp(input.attributes), + events: [], + links: [], + status: { code: "STATUS_CODE_OK" }, + flags: 1, + }, + ], + }, + ], + }, + ], + }; +} + +export function productAnalyticsUrlFromConfig(input: { + readonly productAnalyticsTracesUrl: string | undefined; + readonly jiraTokenProxyUrl: string | undefined; +}): string | undefined { + return ( + input.productAnalyticsTracesUrl ?? + (input.jiraTokenProxyUrl + ? `${input.jiraTokenProxyUrl.replace(/\/+$/, "")}/api/otel/traces` + : undefined) + ); +} + +export function shouldAttachJiraProof(input: { + readonly targetUrl: string; + readonly jiraTokenProxyUrl: string | undefined; +}): boolean { + if (!input.jiraTokenProxyUrl) return false; + try { + return new URL(input.targetUrl).origin === new URL(input.jiraTokenProxyUrl).origin; + } catch { + return false; + } +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3e0e32fd45d..f0c8f8712a7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -109,6 +109,7 @@ import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { deriveLatestProviderUsageSnapshot } from "../../lib/providerUsage"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; +import { recordClientProductSpan } from "../../observability/clientTracing"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -623,6 +624,30 @@ export const ChatComposer = memo( () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); + const recordComposerSubmit = useCallback(() => { + recordClientProductSpan("marcode.ui.composer.submit", { + provider: selectedProvider, + "attachment.count": composerImagesRef.current.length, + "terminal_context.count": composerTerminalContextsRef.current.length, + }); + }, [composerImagesRef, composerTerminalContextsRef, selectedProvider]); + const handleComposerSubmit = useCallback( + (event?: { preventDefault: () => void }) => { + recordComposerSubmit(); + onSend(event); + }, + [onSend, recordComposerSubmit], + ); + const handleProviderModelSelect = useCallback( + (provider: ProviderKind, model: string) => { + recordClientProductSpan("marcode.ui.provider.changed", { + provider, + "model.family": normalizeModelSlug(model), + }); + onProviderModelSelect(provider, model); + }, + [onProviderModelSelect], + ); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo< Record> @@ -1487,7 +1512,7 @@ export const ChatComposer = memo( } } if (key === "Enter" && !event.shiftKey) { - void onSend(); + handleComposerSubmit(); return true; } return false; @@ -1710,7 +1735,7 @@ export const ChatComposer = memo( return (
@@ -1925,7 +1950,7 @@ export const ChatComposer = memo( onOpenChange={(open) => { setIsComposerModelPickerOpen(open); }} - onProviderModelChange={onProviderModelSelect} + onProviderModelChange={handleProviderModelSelect} /> {isComposerFooterCompact ? ( diff --git a/apps/web/src/components/settings/JiraSettingsSection.tsx b/apps/web/src/components/settings/JiraSettingsSection.tsx index b957c195031..169a218a1af 100644 --- a/apps/web/src/components/settings/JiraSettingsSection.tsx +++ b/apps/web/src/components/settings/JiraSettingsSection.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { ensureNativeApi, readNativeApi } from "../../nativeApi"; import { jiraConnectionStatusQueryOptions, jiraQueryKeys } from "../../lib/jiraReactQuery"; import { getServerHttpOrigin } from "../../env"; +import { recordClientProductSpan } from "../../observability/clientTracing"; import { Button } from "../ui/button"; export function JiraSettingsSection() { @@ -22,6 +23,7 @@ export function JiraSettingsSection() { }, [queryClient]); const handleConnect = () => { + recordClientProductSpan("marcode.ui.jira.connect.clicked"); const origin = getServerHttpOrigin(); window.open(`${origin}/api/jira/auth`, "_blank"); }; diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 498e99e6d15..9c54fc59f09 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -1,4 +1,4 @@ -import { Exit, Layer, ManagedRuntime, Scope, Tracer } from "effect"; +import { Context, Exit, Layer, ManagedRuntime, Option, Scope, Tracer } from "effect"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; @@ -50,6 +50,30 @@ export function configureClientTracing(config: ClientTracingConfig = {}): Promis return pendingConfiguration; } +export function recordClientProductSpan( + name: string, + attributes: Readonly> = {}, +): void { + const tracer = activeDelegate; + if (!tracer) return; + + const startTime = BigInt(Date.now()) * 1_000_000n; + const span = tracer.span({ + name, + parent: Option.none(), + annotations: Context.empty(), + links: [], + startTime, + kind: "internal", + root: true, + sampled: true, + }); + for (const [key, value] of Object.entries(attributes)) { + if (value !== undefined) span.attribute(key, value); + } + span.end(startTime + 1_000_000n, Exit.void); +} + async function applyClientTracingConfig(config: ClientTracingConfig): Promise { const otlpTracesUrl = resolvePrimaryEnvironmentHttpUrl("/api/observability/v1/traces"); const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b5ec8a413be..7c7db5621e4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -53,7 +53,7 @@ import { getPrimaryEnvironmentConnection, startEnvironmentConnectionService, } from "../environments/runtime"; -import { configureClientTracing } from "../observability/clientTracing"; +import { configureClientTracing, recordClientProductSpan } from "../observability/clientTracing"; import { ensurePrimaryEnvironmentReady, resolveInitialServerAuthGateState, @@ -232,10 +232,18 @@ function RuntimeToolOutputBootstrap() { } function AuthenticatedTracingBootstrap() { + const pathname = useLocation({ select: (location) => location.pathname }); + useEffect(() => { void configureClientTracing(); }, []); + useEffect(() => { + recordClientProductSpan("marcode.ui.route.changed", { + route: pathname, + }); + }, [pathname]); + return null; } diff --git a/bun.lock b/bun.lock index 5ca6ae53d7d..e4328e4d5a1 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,7 @@ "@types/react-dom": "^19.1.6", "tailwindcss": "^4.1.8", "typescript": "catalog:", + "vitest": "catalog:", }, }, "apps/server": { @@ -1663,7 +1664,7 @@ "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -1841,12 +1842,8 @@ "@marcode/web/lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="], - "@marcode/web/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], - "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], @@ -1859,12 +1856,12 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "@tailwindcss/vite/@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], "@tailwindcss/vite/@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + "@tailwindcss/vite/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + "@tanstack/pacer/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],