Skip to content

Commit 1cc2a7d

Browse files
authored
Merge pull request #83 from tyulyukov/marcode/otel-analytics
feat(telemetry): add otel analytics ingestion and export
2 parents 5a55350 + 8062391 commit 1cc2a7d

25 files changed

Lines changed: 1509 additions & 22 deletions

File tree

apps/landing/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "next dev --turbopack --port 3100",
88
"build": "next build",
99
"start": "next start",
10-
"typecheck": "tsc --noEmit"
10+
"typecheck": "tsc --noEmit",
11+
"test": "vitest run --passWithNoTests"
1112
},
1213
"dependencies": {
1314
"@radix-ui/react-slot": "^1.2.4",
@@ -25,6 +26,7 @@
2526
"@types/react": "^19.1.6",
2627
"@types/react-dom": "^19.1.6",
2728
"tailwindcss": "^4.1.8",
28-
"typescript": "catalog:"
29+
"typescript": "catalog:",
30+
"vitest": "catalog:"
2931
}
3032
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ingestOtelTraces } from "~/lib/otelIngest";
2+
3+
export const runtime = "nodejs";
4+
5+
const CORS_HEADERS = {
6+
"Access-Control-Allow-Origin": "*",
7+
"Access-Control-Allow-Methods": "POST, OPTIONS",
8+
"Access-Control-Allow-Headers": "content-type, x-marcode-jira-access-token",
9+
"Access-Control-Max-Age": "600",
10+
} as const;
11+
12+
export function OPTIONS(): Response {
13+
return new Response(null, {
14+
status: 204,
15+
headers: CORS_HEADERS,
16+
});
17+
}
18+
19+
export async function POST(request: Request): Promise<Response> {
20+
const result = await ingestOtelTraces(request);
21+
if (result.status === 204) {
22+
return new Response(null, { status: 204, headers: CORS_HEADERS });
23+
}
24+
return Response.json(result.body ?? { error: "OTEL ingest failed" }, {
25+
status: result.status,
26+
headers: CORS_HEADERS,
27+
});
28+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
import { __resetOtelIngestForTests, ingestOtelTraces } from "./otelIngest";
4+
5+
const makePayload = () => ({
6+
resourceSpans: [
7+
{
8+
resource: {
9+
attributes: [
10+
{ key: "service.name", value: { stringValue: "marcode-web" } },
11+
{ key: "project.name", value: { stringValue: "MarCode" } },
12+
{ key: "auth.token", value: { stringValue: "secret" } },
13+
],
14+
},
15+
scopeSpans: [
16+
{
17+
scope: { name: "test", attributes: [] },
18+
spans: [
19+
{
20+
traceId: "1".repeat(32),
21+
spanId: "2".repeat(16),
22+
name: "marcode.ui.composer.submit",
23+
kind: 1,
24+
startTimeUnixNano: "1",
25+
endTimeUnixNano: "2",
26+
attributes: [
27+
{ key: "provider", value: { stringValue: "codex" } },
28+
{ key: "prompt.text", value: { stringValue: "do not forward" } },
29+
],
30+
events: [
31+
{
32+
name: "event",
33+
timeUnixNano: "1",
34+
attributes: [{ key: "stdout", value: { stringValue: "nope" } }],
35+
},
36+
],
37+
links: [],
38+
status: { code: "STATUS_CODE_OK" },
39+
},
40+
],
41+
},
42+
],
43+
},
44+
],
45+
});
46+
47+
function request(body: unknown, headers?: Record<string, string>) {
48+
return new Request("https://marcode.dev/api/otel/traces", {
49+
method: "POST",
50+
headers: {
51+
"content-type": "application/json",
52+
...headers,
53+
},
54+
body: typeof body === "string" ? body : JSON.stringify(body),
55+
});
56+
}
57+
58+
afterEach(() => {
59+
__resetOtelIngestForTests();
60+
vi.restoreAllMocks();
61+
});
62+
63+
describe("otel ingest", () => {
64+
it("sanitizes and forwards valid OTLP payloads", async () => {
65+
const forwarded: unknown[] = [];
66+
const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
67+
forwarded.push(JSON.parse(String(init?.body)));
68+
return new Response(null, { status: 204 });
69+
}) as unknown as typeof fetch;
70+
71+
const result = await ingestOtelTraces(request(makePayload()), {
72+
collectorUrl: "https://collector.example/v1/traces",
73+
fetch: fetchMock,
74+
});
75+
76+
expect(result.status).toBe(204);
77+
expect(forwarded).toHaveLength(1);
78+
const payload = forwarded[0] as ReturnType<typeof makePayload>;
79+
const attrs = payload.resourceSpans[0]!.resource.attributes;
80+
expect(attrs).toContainEqual({ key: "service.name", value: { stringValue: "marcode" } });
81+
expect(attrs).toContainEqual({
82+
key: "marcode.original_service_name",
83+
value: { stringValue: "marcode-web" },
84+
});
85+
expect(attrs.some((attr) => attr.key === "auth.token")).toBe(false);
86+
const spanAttrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes;
87+
expect(spanAttrs).toEqual([{ key: "provider", value: { stringValue: "codex" } }]);
88+
expect(payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.events![0]!.attributes).toEqual([]);
89+
});
90+
91+
it("marks Genesis users from verified Jira email and bypasses rate limits", async () => {
92+
const forwarded: unknown[] = [];
93+
const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
94+
if (String(url) === "https://api.atlassian.com/me") {
95+
return Response.json({ email: "dev@gen.tech", account_id: "jira-account" });
96+
}
97+
forwarded.push(JSON.parse(String(init?.body)));
98+
return new Response(null, { status: 204 });
99+
}) as unknown as typeof fetch;
100+
101+
for (let index = 0; index < 3; index++) {
102+
const result = await ingestOtelTraces(
103+
request(makePayload(), { "x-marcode-jira-access-token": "jira-token" }),
104+
{
105+
collectorUrl: "https://collector.example/v1/traces",
106+
fetch: fetchMock,
107+
rateLimitPerMinute: 1,
108+
clientKey: "same-client",
109+
},
110+
);
111+
expect(result.status).toBe(204);
112+
}
113+
114+
const payload = forwarded[0] as ReturnType<typeof makePayload>;
115+
expect(payload.resourceSpans[0]!.resource.attributes).toContainEqual({
116+
key: "analytics.user.is_genesis",
117+
value: { boolValue: true },
118+
});
119+
});
120+
121+
it("rate limits non-Genesis users", async () => {
122+
const fetchMock = vi.fn(
123+
async () => new Response(null, { status: 204 }),
124+
) as unknown as typeof fetch;
125+
126+
const first = await ingestOtelTraces(request(makePayload()), {
127+
collectorUrl: "https://collector.example/v1/traces",
128+
fetch: fetchMock,
129+
rateLimitPerMinute: 1,
130+
clientKey: "same-client",
131+
});
132+
const second = await ingestOtelTraces(request(makePayload()), {
133+
collectorUrl: "https://collector.example/v1/traces",
134+
fetch: fetchMock,
135+
rateLimitPerMinute: 1,
136+
clientKey: "same-client",
137+
});
138+
139+
expect(first.status).toBe(204);
140+
expect(second.status).toBe(429);
141+
});
142+
143+
it("rejects invalid and oversized payloads", async () => {
144+
const invalid = await ingestOtelTraces(request({ nope: true }), {
145+
collectorUrl: "https://collector.example/v1/traces",
146+
});
147+
const oversized = await ingestOtelTraces(request(makePayload()), {
148+
collectorUrl: "https://collector.example/v1/traces",
149+
maxBodyBytes: 10,
150+
});
151+
152+
expect(invalid.status).toBe(400);
153+
expect(oversized.status).toBe(413);
154+
});
155+
156+
it("returns 502 when collector forwarding fails", async () => {
157+
const fetchMock = vi.fn(
158+
async () => new Response("bad", { status: 500 }),
159+
) as unknown as typeof fetch;
160+
161+
const result = await ingestOtelTraces(request(makePayload()), {
162+
collectorUrl: "https://collector.example/v1/traces",
163+
fetch: fetchMock,
164+
});
165+
166+
expect(result.status).toBe(502);
167+
});
168+
});

0 commit comments

Comments
 (0)