Skip to content

Commit f6d8e20

Browse files
committed
Add TypeScript e2e OTEL tracing example
1 parent 23fb664 commit f6d8e20

8 files changed

Lines changed: 484 additions & 0 deletions

File tree

typescript/tracing/.gitignore

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Node
2+
node_modules
3+
dist
4+
5+
# debug
6+
npm-debug.log*
7+
8+
# env files
9+
.env*
10+
11+
# typescript
12+
*.tsbuildinfo
13+
14+
# Restate
15+
.restate
16+
restate-data

typescript/tracing/README.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# End-to-End OpenTelemetry Tracing with Restate
2+
3+
This example demonstrates distributed tracing across a fictional multi-tier system:
4+
5+
```
6+
┌──────────┐ ┌─────────────┐ ┌─────────────────┐ ┌────────────┐
7+
│ Client │────▶│ Restate │────▶│ Greeter Service │────▶│ Downstream │
8+
│ App │ │ Server │ │ (SDK/Node) │ │ Service │
9+
└──────────┘ └─────────────┘ └─────────────────┘ └────────────┘
10+
│ │ │ │
11+
│ │ │ │
12+
▼ ▼ ▼ ▼
13+
┌────────────────────────────────────────────────────────────────────────┐
14+
│ Jaeger │
15+
└────────────────────────────────────────────────────────────────────────┘
16+
```
17+
18+
**What gets traced:**
19+
20+
1. **Client App** - Creates the root span and injects W3C trace context into the Restate request
21+
2. **Restate Server** - Receives trace context, emits spans for ingress requests and handler invocations
22+
3. **Greeter Service** - SDK handler that creates custom spans and propagates context to downstream calls
23+
4. **Downstream Service** - Receives and logs the propagated trace headers
24+
25+
## Prerequisites
26+
27+
- Node.js 18+
28+
- Docker (for Jaeger)
29+
30+
## Setup
31+
32+
### 1. Start Jaeger
33+
34+
```bash
35+
docker run -d --name jaeger \
36+
-p 4317:4317 \
37+
-p 16686:16686 \
38+
jaegertracing/all-in-one:latest
39+
```
40+
41+
Jaeger UI will be available at `http://localhost:16686`
42+
43+
### 2. Install dependencies
44+
45+
```bash
46+
npm install
47+
```
48+
49+
### 3. Start Restate Server with tracing enabled
50+
51+
```bash
52+
npx @restatedev/restate-server --tracing-endpoint http://localhost:4317
53+
```
54+
55+
### 4. Start the downstream service (terminal 1)
56+
57+
```bash
58+
npm run downstream
59+
```
60+
61+
### 5. Start the Greeter service (terminal 2)
62+
63+
```bash
64+
npm run service
65+
```
66+
67+
### 6. Register the service with Restate
68+
69+
```bash
70+
npx @restatedev/restate deployments register http://localhost:9080
71+
```
72+
73+
### 7. Run the client
74+
75+
```bash
76+
npm run client Alice
77+
```
78+
79+
## Viewing Traces
80+
81+
After running the client, you'll see output like:
82+
83+
```
84+
Root Trace ID: abc123...
85+
View in Jaeger: http://localhost:16686/trace/abc123...
86+
```
87+
88+
Open the Jaeger link to see the complete distributed trace spanning all four components.
89+
90+
## What You'll See in Jaeger
91+
92+
The trace will show spans from all four services:
93+
94+
- **client-app**: The root `client-request` span
95+
- **Greeter**: Restate server spans for ingress, invoke, and journal operations
96+
- **restate-greeter-service**: Custom `Greeter.greet` span with events
97+
- **downstream-service**: `handle-request` span (may show errors due to 50% failure rate)
98+
99+
![Jaeger trace showing end-to-end spans](trace.png)
100+
101+
## Key Pattern: Extracting Trace Context in TypeScript SDK
102+
103+
The Restate server propagates W3C trace context to handlers via HTTP headers. In the TypeScript SDK, you need to manually extract this from `ctx.request().attemptHeaders`:
104+
105+
```typescript
106+
import { context, propagation, Context } from "@opentelemetry/api";
107+
108+
function extractTraceContext(ctx: restate.Context): Context {
109+
const headers = ctx.request().attemptHeaders;
110+
const traceparent = headers.get("traceparent");
111+
const tracestate = headers.get("tracestate");
112+
113+
const carrier: Record<string, string> = {};
114+
if (traceparent) {
115+
carrier["traceparent"] = Array.isArray(traceparent) ? traceparent[0] : traceparent;
116+
}
117+
if (tracestate) {
118+
carrier["tracestate"] = Array.isArray(tracestate) ? tracestate[0] : tracestate;
119+
}
120+
return propagation.extract(context.active(), carrier);
121+
}
122+
```
123+
124+
Then run your handler logic within that context:
125+
126+
```typescript
127+
const traceContext = extractTraceContext(ctx);
128+
return context.with(traceContext, () => {
129+
const span = tracer.startSpan("MyHandler");
130+
// ... your logic here, span is now a child of Restate's span
131+
});
132+
```
133+
134+
## Files
135+
136+
- `src/client.ts` - Client app that initiates traced requests
137+
- `src/restate-service.ts` - Restate Greeter service with OpenTelemetry instrumentation
138+
- `src/downstream.ts` - HTTP server with tracing and random failure rate

typescript/tracing/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "restate-tracing-example",
3+
"version": "0.0.1",
4+
"description": "End-to-end OpenTelemetry tracing with Restate",
5+
"type": "commonjs",
6+
"scripts": {
7+
"build": "tsc --noEmitOnError",
8+
"service": "tsx ./src/restate-service.ts",
9+
"client": "tsx ./src/client.ts",
10+
"downstream": "tsx ./src/downstream.ts"
11+
},
12+
"dependencies": {
13+
"@opentelemetry/api": "^1.9.0",
14+
"@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
15+
"@opentelemetry/instrumentation-http": "^0.211.0",
16+
"@opentelemetry/resources": "^1.30.0",
17+
"@opentelemetry/sdk-node": "^0.57.0",
18+
"@opentelemetry/semantic-conventions": "^1.28.0",
19+
"@restatedev/restate-sdk": "^1.10.2"
20+
},
21+
"devDependencies": {
22+
"@types/node": "^20.14.2",
23+
"tsx": "^4.19.2",
24+
"typescript": "^5.4.5"
25+
}
26+
}

typescript/tracing/src/client.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// OpenTelemetry must be initialized before other imports
2+
import { NodeSDK } from "@opentelemetry/sdk-node";
3+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
4+
import { Resource } from "@opentelemetry/resources";
5+
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
6+
import { trace, context, propagation, SpanKind, SpanStatusCode } from "@opentelemetry/api";
7+
8+
const sdk = new NodeSDK({
9+
resource: new Resource({
10+
[ATTR_SERVICE_NAME]: "client-app",
11+
}),
12+
traceExporter: new OTLPTraceExporter({
13+
url: "http://localhost:4317",
14+
}),
15+
});
16+
17+
sdk.start();
18+
19+
const RESTATE_INGRESS = "http://localhost:8080";
20+
const tracer = trace.getTracer("client-app");
21+
22+
async function main() {
23+
const name = process.argv[2] || "World";
24+
25+
console.log("=== Client App ===");
26+
console.log(`Calling Restate Greeter service with name: ${name}`);
27+
28+
// Create the root span for this request
29+
const rootSpan = tracer.startSpan("client-request", {
30+
kind: SpanKind.CLIENT,
31+
attributes: {
32+
"request.name": name,
33+
},
34+
});
35+
36+
try {
37+
const result = await context.with(trace.setSpan(context.active(), rootSpan), async () => {
38+
const headers: Record<string, string> = {
39+
"Content-Type": "application/json",
40+
};
41+
42+
propagation.inject(context.active(), headers);
43+
console.log(`Injected W3C trace context headers:`, headers);
44+
45+
const traceId = rootSpan.spanContext().traceId;
46+
console.log(`Root Trace ID: ${traceId}`);
47+
console.log(`View in Jaeger: http://localhost:16686/trace/${traceId}`);
48+
console.log("");
49+
50+
const response = await fetch(`${RESTATE_INGRESS}/Greeter/greet`, {
51+
method: "POST",
52+
headers,
53+
body: JSON.stringify(name),
54+
});
55+
56+
if (!response.ok) {
57+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
58+
}
59+
60+
return response.json();
61+
});
62+
63+
rootSpan.addEvent("response_received", {
64+
"response.value": JSON.stringify(result),
65+
});
66+
rootSpan.setStatus({ code: SpanStatusCode.OK });
67+
68+
console.log(`Response: ${JSON.stringify(result)}`);
69+
} catch (err) {
70+
rootSpan.setStatus({
71+
code: SpanStatusCode.ERROR,
72+
message: err instanceof Error ? err.message : "Unknown error",
73+
});
74+
console.error("Error:", err);
75+
process.exitCode = 1;
76+
} finally {
77+
rootSpan.end();
78+
await sdk.shutdown();
79+
}
80+
}
81+
82+
main();
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// OpenTelemetry must be initialized before other imports
2+
import { NodeSDK } from "@opentelemetry/sdk-node";
3+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
4+
import { Resource } from "@opentelemetry/resources";
5+
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
6+
import { trace, context, propagation, SpanKind, SpanStatusCode } from "@opentelemetry/api";
7+
import { createServer } from "node:http";
8+
9+
const sdk = new NodeSDK({
10+
resource: new Resource({
11+
[ATTR_SERVICE_NAME]: "downstream-service",
12+
}),
13+
traceExporter: new OTLPTraceExporter({
14+
url: "http://localhost:4317",
15+
}),
16+
});
17+
18+
sdk.start();
19+
20+
const PORT = 3000;
21+
const FAILURE_RATE = 0.5; // 50% chance
22+
23+
const tracer = trace.getTracer("downstream-service");
24+
25+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
26+
27+
const server = createServer((req, res) => {
28+
// Extract trace context from incoming headers
29+
const carrier: Record<string, string> = {};
30+
const traceparent = req.headers["traceparent"];
31+
const tracestate = req.headers["tracestate"];
32+
33+
if (traceparent) {
34+
carrier["traceparent"] = Array.isArray(traceparent) ? traceparent[0] : traceparent;
35+
}
36+
if (tracestate) {
37+
carrier["tracestate"] = Array.isArray(tracestate) ? tracestate[0] : tracestate;
38+
}
39+
40+
const traceContext = propagation.extract(context.active(), carrier);
41+
42+
// Run request handling within the extracted trace context
43+
context.with(traceContext, async () => {
44+
const span = tracer.startSpan("handle-request", {
45+
kind: SpanKind.SERVER,
46+
attributes: {
47+
"http.method": req.method,
48+
"http.url": req.url,
49+
},
50+
});
51+
52+
try {
53+
// Simulate some work
54+
await sleep(50 + Math.random() * 100);
55+
56+
// Random failure
57+
if (Math.random() < FAILURE_RATE) {
58+
span.setStatus({
59+
code: SpanStatusCode.ERROR,
60+
message: "Random failure",
61+
});
62+
span.addEvent("failure_triggered", { rate: FAILURE_RATE });
63+
64+
res.writeHead(500, { "Content-Type": "application/json" });
65+
res.end(JSON.stringify({ error: "Random failure", receivedTrace: !!traceparent }));
66+
return;
67+
}
68+
69+
span.addEvent("processing_complete");
70+
span.setStatus({ code: SpanStatusCode.OK });
71+
72+
res.writeHead(200, { "Content-Type": "application/json" });
73+
res.end(JSON.stringify({ status: "ok", receivedTrace: !!traceparent }));
74+
} finally {
75+
span.end();
76+
}
77+
});
78+
});
79+
80+
server.listen(PORT, () => {
81+
console.log(`Downstream service listening on http://localhost:${PORT}`);
82+
console.log(`Failure rate: ${FAILURE_RATE * 100}%`);
83+
});
84+
85+
process.on("SIGTERM", () => {
86+
sdk.shutdown().then(() => process.exit(0));
87+
});

0 commit comments

Comments
 (0)