Skip to content

Commit e8a420f

Browse files
kristojorgclaude
andcommitted
add prometheus metrics endpoint
- Add @effect/opentelemetry with PrometheusExporter - Expose /metrics on port 9464 - Track messages processed, list operations, cursor position - Track memory (heap, RSS) every 30 seconds - Fix BufferOverflowError class, index exports, Cursor double-write Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4f9af88 commit e8a420f

8 files changed

Lines changed: 92 additions & 9 deletions

File tree

bun.lockb

10.2 KB
Binary file not shown.

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,18 @@
4747
"dependencies": {
4848
"@atcute/cbor": "^2.1.1",
4949
"@atproto/api": "^0.14.21",
50+
"@effect/opentelemetry": "^0.60.0",
5051
"@effect/platform": "^0.72.2",
5152
"@effect/platform-bun": "^0.52.2",
53+
"@opentelemetry/api": "^1.9.0",
54+
"@opentelemetry/core": "^2.4.0",
55+
"@opentelemetry/exporter-prometheus": "^0.210.0",
56+
"@opentelemetry/resources": "^2.4.0",
57+
"@opentelemetry/sdk-logs": "^0.210.0",
58+
"@opentelemetry/sdk-metrics": "^2.4.0",
59+
"@opentelemetry/sdk-trace-base": "^2.4.0",
60+
"@opentelemetry/sdk-trace-node": "^2.4.0",
61+
"@opentelemetry/semantic-conventions": "^1.39.0",
5262
"dotenv": "^16.4.7",
5363
"effect": "^3.12.2",
5464
"semver": "^7.6.3"

src/Cursor.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,6 @@ const writeCursor = (env: Env, fs: FileSystem.FileSystem) => (cursor: number) =>
7979
Effect.gen(function*() {
8080
yield* Effect.log("Writing cursor to file: ", cursor)
8181
const { labelerCursorFilepath } = env
82-
yield* Effect.tryPromise({
83-
try: () => Bun.write(labelerCursorFilepath, cursor.toString()),
84-
catch: (cause) =>
85-
new CursorError({ message: "Failed to write cursor", cause }),
86-
})
8782
yield* fs.writeFileString(labelerCursorFilepath, cursor.toString())
8883
}).pipe(Effect.catchAllCause(Effect.logError))
8984

src/LabelWatcher.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { AtpListAccountAgent } from "@/AtpAgent";
22
import { Env } from "@/Environment";
3+
import { cursorGauge, listOperations, messagesProcessed } from "@/Metrics";
34
import { decodeFirst } from "@atcute/cbor";
4-
import { Data, Effect, Layer, Schema, Stream } from "effect";
5+
import { Data, Effect, Layer, Metric, Schema, Stream } from "effect";
56
import { Cursor } from "./Cursor";
67
import { RetryingSocket } from "./RetryingSocket";
78
import { MessageLabels, parseSubscribeLabelsMessage } from "./schema";
@@ -67,15 +68,22 @@ export const LabelWatcherLive = Layer.scopedDiscard(run).pipe(
6768
*/
6869
const handleLabel = (agent: AtpListAccountAgent) => (label: MessageLabels) =>
6970
Effect.gen(function* () {
71+
yield* Metric.increment(messagesProcessed);
72+
7073
const labels = label.body.labels;
7174
for (const label of labels) {
7275
if (label.neg) {
7376
yield* agent.removeUserFromList(label.uri, label.val);
77+
yield* Metric.increment(Metric.tagged(listOperations, "op", "remove"));
7478
continue;
7579
}
7680
yield* agent.addUserToList(label.uri, label.val);
81+
yield* Metric.increment(Metric.tagged(listOperations, "op", "add"));
7782
}
78-
return label.body.seq;
83+
84+
const seq = label.body.seq;
85+
yield* Metric.set(cursorGauge, seq);
86+
return seq;
7987
});
8088

8189
const parseMessage = (u: Uint8Array) =>

src/Metrics.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as NodeSdk from "@effect/opentelemetry/NodeSdk"
2+
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"
3+
import { Config, Effect, Layer, Metric, Schedule } from "effect"
4+
5+
// App metrics
6+
export const messagesProcessed = Metric.counter("messages_processed_total", {
7+
description: "Total websocket messages processed",
8+
})
9+
10+
export const cursorGauge = Metric.gauge("cursor_value", {
11+
description: "Current cursor position",
12+
})
13+
14+
export const listOperations = Metric.counter("list_operations_total", {
15+
description: "List add/remove operations",
16+
})
17+
18+
export const wsReconnects = Metric.counter("websocket_reconnects_total", {
19+
description: "WebSocket reconnection attempts",
20+
})
21+
22+
// Memory metrics
23+
export const heapUsed = Metric.gauge("nodejs_heap_used_bytes", {
24+
description: "Node.js heap used in bytes",
25+
})
26+
27+
export const rss = Metric.gauge("nodejs_rss_bytes", {
28+
description: "Node.js resident set size in bytes",
29+
})
30+
31+
// Record memory metrics every 30 seconds
32+
export const recordMemoryMetrics = Effect.gen(function* () {
33+
const mem = process.memoryUsage()
34+
yield* Metric.set(heapUsed, mem.heapUsed)
35+
yield* Metric.set(rss, mem.rss)
36+
yield* Effect.log(
37+
`Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, rss=${Math.round(mem.rss / 1024 / 1024)}MB`
38+
)
39+
}).pipe(Effect.repeat(Schedule.spaced("30 seconds")), Effect.forkScoped)
40+
41+
// Prometheus exporter config
42+
const MetricsConfig = Config.integer("METRICS_PORT").pipe(
43+
Config.withDefault(9464)
44+
)
45+
46+
// NodeSdk layer with Prometheus exporter
47+
export const MetricsLive = Layer.unwrapEffect(
48+
Effect.gen(function* () {
49+
const port = yield* MetricsConfig
50+
yield* Effect.log(`Starting Prometheus metrics exporter on port ${port}`)
51+
52+
return NodeSdk.layer(() => ({
53+
resource: {
54+
serviceName: "bsky-label-watcher",
55+
serviceVersion: "1.0.0",
56+
},
57+
metricReader: new PrometheusExporter({ port }),
58+
}))
59+
})
60+
)
61+
62+
// Layer that starts memory recording
63+
export const MemoryMetricsLive = Layer.scopedDiscard(recordMemoryMetrics)

src/RetryingSocket.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Socket } from "@effect/platform";
22
import type { SocketError } from "@effect/platform/Socket";
3-
import { Cause, Effect, Schedule, Stream } from "effect";
3+
import { Data, Effect, Schedule, Stream } from "effect";
4+
5+
class BufferOverflowError extends Data.TaggedError("BufferOverflowError")<{
6+
message: string;
7+
}> {}
48

59
/**
610
* A stream that will reconnect to the websocket on error.

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Core services and layers
22
export { LabelWatcherLive } from "./LabelWatcher"
33
export { Env } from "./Environment"
4-
export { AtpAgent } from "./AtpAgent"
4+
export { AtpListAccountAgent, LabelerInfo, make as makeAtpAgent } from "./AtpAgent"
55
export { ListService } from "./ListService"
66
export { Cursor } from "./Cursor"
77

src/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ApiLive } from "@/HttpApi"
22
import { LabelWatcherLive } from "@/LabelWatcher"
33
import { LoggerLive } from "@/logger"
4+
import { MemoryMetricsLive, MetricsLive } from "@/Metrics"
45
import { Layer } from "effect"
56
import "dotenv/config"
67
import { BunRuntime } from "@effect/platform-bun"
@@ -31,8 +32,10 @@ import { Env } from "@/Environment"
3132
export const MainLiveLayer = Layer.mergeAll(
3233
LabelWatcherLive.pipe(Layer.provide(Env.Default)),
3334
ApiLive,
35+
MemoryMetricsLive,
3436
).pipe(
3537
Layer.provide(LoggerLive),
38+
Layer.provide(MetricsLive),
3639
)
3740

3841
Layer.launch(MainLiveLayer).pipe(BunRuntime.runMain)

0 commit comments

Comments
 (0)