Skip to content

Commit 46a4a2e

Browse files
committed
Next.js client-side instrumentation example
1 parent 17dbddd commit 46a4a2e

File tree

22 files changed

+6336
-0
lines changed

22 files changed

+6336
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.*
7+
.yarn/*
8+
!.yarn/patches
9+
!.yarn/plugins
10+
!.yarn/releases
11+
!.yarn/versions
12+
13+
# testing
14+
/coverage
15+
16+
# next.js
17+
/.next/
18+
/out/
19+
20+
# production
21+
/build
22+
23+
# misc
24+
.DS_Store
25+
*.pem
26+
27+
# debug
28+
npm-debug.log*
29+
yarn-debug.log*
30+
yarn-error.log*
31+
.pnpm-debug.log*
32+
33+
# env files (can opt-in for committing if needed)
34+
.env*
35+
36+
# vercel
37+
.vercel
38+
39+
# typescript
40+
*.tsbuildinfo
41+
next-env.d.ts
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Example for a Next.js client/server distributed OTel instrumentation with Logfire
2+
3+
4+
The example showcases how a fetch request initiated from the browser can propagate to the server and then to a third-party service, all while being instrumented with OpenTelemetry. The example uses the Logfire OTel SDK for both the client and server sides.
5+
6+
## Highlights
7+
8+
- The `ClientInstrumentationProvider` is a client-only component that instruments the browser fetch.
9+
- To avoid exposing the write token, the middleware.ts proxies the logfire `/v1/traces` request.
10+
- The instrumentation.ts file is the standard `@vercel/otel` setup.
11+
- The `.env` should look like this:
12+
13+
```sh
14+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://logfire-api.pydantic.dev/v1/traces
15+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://logfire-api.pydantic.dev/v1/metrics
16+
OTEL_EXPORTER_OTLP_HEADERS='Authorization=your-token'
17+
LOGFIRE_TOKEN='your-token'
18+
```
19+
20+
NOTE: alternatively, if you're not sure about the connection between the client and the server, you can host the proxy at a different location (e.g. Cloudflare).
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as logfire from "@pydantic/logfire-api";
2+
3+
export async function GET() {
4+
logfire.info("server span");
5+
return Response.json({ message: "Hello World!" });
6+
}
7+
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ZoneContextManager } from "@opentelemetry/context-zone";
2+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
3+
import { registerInstrumentations } from "@opentelemetry/instrumentation";
4+
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
5+
import { Resource } from "@opentelemetry/resources";
6+
import {
7+
RandomIdGenerator,
8+
SimpleSpanProcessor,
9+
WebTracerProvider,
10+
} from "@opentelemetry/sdk-trace-web";
11+
import {
12+
ATTR_SERVICE_NAME,
13+
ATTR_SERVICE_VERSION,
14+
} from "@opentelemetry/semantic-conventions";
15+
import { ReactNode, useEffect } from "react";
16+
17+
// JS port of https://github.com/pydantic/logfire/blob/main/logfire/_internal/ulid.py without the parameters
18+
function ulid(): bigint {
19+
// Timestamp: first 6 bytes of the ULID (48 bits)
20+
// Note that it's not important that this timestamp is super precise or unique.
21+
// It just needs to be roughly monotonically increasing so that the ULID is sortable, at least for our purposes.
22+
let result = BigInt(Date.now());
23+
24+
// Randomness: next 10 bytes of the ULID (80 bits)
25+
const randomness = crypto.getRandomValues(new Uint8Array(10));
26+
for (const segment of randomness) {
27+
result <<= BigInt(8);
28+
result |= BigInt(segment);
29+
}
30+
31+
return result;
32+
}
33+
34+
class ULIDGenerator extends RandomIdGenerator {
35+
override generateTraceId = () => {
36+
return ulid().toString(16).padStart(32, "0");
37+
};
38+
}
39+
40+
export default function ClientInstrumentationProvider(
41+
{ children }: { children: ReactNode },
42+
) {
43+
useEffect(() => {
44+
const url = new URL(window.location.href);
45+
url.pathname = "/client-traces";
46+
const resource = new Resource({
47+
[ATTR_SERVICE_NAME]: "logfire-frontend",
48+
[ATTR_SERVICE_VERSION]: "0.0.1",
49+
});
50+
51+
const provider = new WebTracerProvider({
52+
resource,
53+
idGenerator: new ULIDGenerator(),
54+
spanProcessors: [
55+
new SimpleSpanProcessor(
56+
new OTLPTraceExporter({ url: url.toString() }),
57+
),
58+
],
59+
});
60+
61+
provider.register({
62+
contextManager: new ZoneContextManager(),
63+
});
64+
65+
registerInstrumentations({
66+
instrumentations: [new FetchInstrumentation()],
67+
});
68+
}, []);
69+
return children;
70+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"use client";
2+
import { useState } from "react";
3+
4+
export default function HelloButton() {
5+
const [message, setMessage] = useState("");
6+
const [loading, setLoading] = useState(false);
7+
8+
const fetchHello = async () => {
9+
setLoading(true);
10+
try {
11+
const response = await fetch("/api/hello");
12+
const data = await response.json();
13+
setMessage(data.message);
14+
} catch {
15+
setMessage("Error fetching data");
16+
} finally {
17+
setLoading(false);
18+
}
19+
};
20+
21+
return (
22+
<div>
23+
<button
24+
onClick={fetchHello}
25+
disabled={loading}
26+
>
27+
{loading ? "Loading..." : "Fetch Hello World"}
28+
</button>
29+
{message && <p>{message}</p>}
30+
</div>
31+
);
32+
}
33+
25.3 KB
Binary file not shown.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
:root {
2+
--background: #ffffff;
3+
--foreground: #171717;
4+
}
5+
6+
@media (prefers-color-scheme: dark) {
7+
:root {
8+
--background: #0a0a0a;
9+
--foreground: #ededed;
10+
}
11+
}
12+
13+
html,
14+
body {
15+
max-width: 100vw;
16+
overflow-x: hidden;
17+
}
18+
19+
body {
20+
color: var(--foreground);
21+
background: var(--background);
22+
font-family: Arial, Helvetica, sans-serif;
23+
-webkit-font-smoothing: antialiased;
24+
-moz-osx-font-smoothing: grayscale;
25+
}
26+
27+
* {
28+
box-sizing: border-box;
29+
padding: 0;
30+
margin: 0;
31+
}
32+
33+
a {
34+
color: inherit;
35+
text-decoration: none;
36+
}
37+
38+
@media (prefers-color-scheme: dark) {
39+
html {
40+
color-scheme: dark;
41+
}
42+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Metadata } from "next";
2+
import { Geist, Geist_Mono } from "next/font/google";
3+
import "./globals.css";
4+
5+
const geistSans = Geist({
6+
variable: "--font-geist-sans",
7+
subsets: ["latin"],
8+
});
9+
10+
const geistMono = Geist_Mono({
11+
variable: "--font-geist-mono",
12+
subsets: ["latin"],
13+
});
14+
15+
export const metadata: Metadata = {
16+
title: "Create Next App",
17+
description: "Generated by create next app",
18+
};
19+
20+
export default function RootLayout({
21+
children,
22+
}: Readonly<{
23+
children: React.ReactNode;
24+
}>) {
25+
return (
26+
<html lang="en">
27+
<body className={`${geistSans.variable} ${geistMono.variable}`}>
28+
{children}
29+
</body>
30+
</html>
31+
);
32+
}

0 commit comments

Comments
 (0)