miniflare
Version:
Fun, full-featured, fully-local simulator for Cloudflare Workers
557 lines (553 loc) • 20.4 kB
JavaScript
// src/workers/observability/collector.worker.ts
import { WorkerEntrypoint } from "cloudflare:workers";
// src/workers/observability/tail-to-store.ts
function toMs(timestamp) {
return typeof timestamp == "number" ? timestamp : timestamp.getTime();
}
function ids(event) {
return event.event.type === "onset" || event.event.type === "spanOpen" ? {
traceId: event.spanContext.traceId,
spanId: event.event.spanId,
parentId: event.spanContext.spanId
} : {
traceId: event.spanContext.traceId,
spanId: event.spanContext.spanId
};
}
function friendlyKind(faasTrigger, name) {
if (faasTrigger)
switch (faasTrigger) {
case "http":
return "http";
case "timer":
return "scheduled";
case "pubsub":
return "queue";
case "email":
return "email";
case "jsrpc":
return "jsrpc";
case "websocket":
return "websocket";
case "trace":
return "trace";
default:
return "worker";
}
let n = name.toLowerCase();
return n.includes("kv") ? "kv" : n.includes("d1") ? "d1" : n.includes("r2") ? "r2" : n.includes("queue") ? "queue" : n.includes("durable") || n.includes("do_") ? "do" : n.includes("cache") ? "cache" : n.includes("fetch") ? "fetch" : "span";
}
function describeTrigger(info) {
switch (info.type) {
case "fetch":
return {
name: info.method,
attributes: {
"faas.trigger": "http",
"http.request.method": info.method,
"url.full": info.url
}
};
case "jsrpc":
return { name: "jsrpc", attributes: { "faas.trigger": "jsrpc" } };
case "scheduled":
return {
name: "scheduled",
attributes: { "faas.trigger": "timer", "faas.cron": info.cron }
};
case "alarm":
return { name: "alarm", attributes: { "faas.trigger": "timer" } };
case "queue":
return {
name: "queue",
attributes: {
"faas.trigger": "pubsub",
"cloudflare.queue.name": info.queueName
}
};
case "email":
return {
name: "email",
attributes: {
"faas.trigger": "email",
"cloudflare.email.to": info.rcptTo
}
};
case "trace":
return { name: "trace", attributes: { "faas.trigger": "trace" } };
case "hibernatableWebSocket":
return {
name: "hibernatableWebSocket",
attributes: { "faas.trigger": "websocket" }
};
default:
return { name: info.type, attributes: { "faas.trigger": "other" } };
}
}
function isErrorOutcome(outcome) {
return outcome !== "ok";
}
var TailToStoreHandler = class {
constructor(store, onset, worker) {
this.store = store;
this.worker = worker;
let { traceId, spanId, parentId } = ids(onset);
if (!spanId)
return;
this.#rootSpanId = spanId, this.#traceId = traceId, this.#startMs = toMs(onset.timestamp), this.#latestEventMs = this.#startMs;
let { name, attributes: triggerAttributes } = describeTrigger(
onset.event.info
), attributes = {};
for (let attr of onset.event.attributes ?? [])
attributes[attr.name] = normalizeAttr(attr.value);
Object.assign(attributes, triggerAttributes), onset.invocationId && (attributes["faas.invocation_id"] = onset.invocationId), this.#invocationBody = onset.event.info.type === "fetch" ? `${onset.event.info.method} ${onset.event.info.url}` : name, this.#spans.set(spanId, {
traceId,
startMs: this.#startMs,
name,
outcome: null,
error: null,
errored: !1,
closed: !1
}), this.#open({
traceId,
spanId,
// A sub-invocation (e.g. downstream of a service binding) carries the
// caller's span as its parent, so it nests into one distributed trace;
// a true top-level invocation has no inherited parent (→ null root).
parentId: parentId ?? null,
service: this.worker ?? null,
name,
kind: friendlyKind(attributes["faas.trigger"], name),
startMs: this.#startMs,
durationMs: null,
outcome: null,
error: null,
attributes
}), this.#flush();
}
store;
worker;
#spans = /* @__PURE__ */ new Map();
#rootSpanId = null;
#traceId = null;
#startMs = null;
#invocationBody = null;
#writes = [];
#rows = /* @__PURE__ */ new Map();
#dirty = /* @__PURE__ */ new Set();
#logs = [];
#latestEventMs = 0;
#lastFlushMs = 0;
spanOpen(event) {
this.#latestEventMs = toMs(event.timestamp);
let { traceId, spanId, parentId } = ids(event);
if (!spanId)
return;
let startMs = toMs(event.timestamp);
this.#spans.set(spanId, {
traceId,
startMs,
name: event.event.name,
outcome: null,
error: null,
errored: !1,
closed: !1
}), this.#open({
traceId,
spanId,
parentId: parentId ?? null,
service: this.worker ?? null,
name: event.event.name,
kind: friendlyKind(void 0, event.event.name),
startMs,
durationMs: null,
outcome: null,
error: null,
attributes: null
});
}
spanClose(event) {
this.#latestEventMs = toMs(event.timestamp);
let { traceId, spanId } = ids(event), pending = spanId ? this.#spans.get(spanId) : void 0;
spanId && pending && (isErrorOutcome(event.event.outcome) && (pending.errored = !0, pending.outcome ??= event.event.outcome), this.#close(traceId, spanId, pending, toMs(event.timestamp), null));
}
attributes(event) {
this.#latestEventMs = toMs(event.timestamp);
let { traceId, spanId } = ids(event);
if (!spanId || !this.#spans.has(spanId))
return;
let attrs = {};
for (let attr of event.event.info)
attrs[attr.name] = normalizeAttr(attr.value);
Object.keys(attrs).length > 0 && this.#merge(traceId, spanId, attrs);
}
return(event) {
this.#latestEventMs = toMs(event.timestamp);
let { traceId, spanId } = ids(event), pending = spanId ? this.#spans.get(spanId) : void 0;
spanId && pending && event.event.info?.type === "fetch" && this.#merge(traceId, spanId, {
"http.response.status_code": event.event.info.statusCode
});
}
log(event) {
this.#latestEventMs = toMs(event.timestamp);
let { traceId, spanId } = ids(event), level = event.event.level === "log" ? "info" : event.event.level;
this.#append({
traceId,
spanId: spanId ?? null,
tsMs: toMs(event.timestamp),
level,
message: serialize(event.event.message),
operation: null
});
}
exception(event) {
this.#latestEventMs = toMs(event.timestamp);
let { traceId, spanId } = ids(event), pending = spanId ? this.#spans.get(spanId) : void 0, type = event.event.name || "Error", message = event.event.message ?? "", head = `${type}: ${message}`, text = event.event.stack ? `${head}
${event.event.stack}` : head;
pending && (pending.errored = !0, pending.outcome = "error", pending.error = head), this.#append({
traceId,
spanId: spanId ?? null,
tsMs: toMs(event.timestamp),
level: "error",
message: serialize(text),
operation: pending?.name ?? null
});
}
async outcome(event) {
let endMs = toMs(event.timestamp);
this.#latestEventMs = endMs;
let traceId = this.#traceId ?? event.spanContext.traceId, root = this.#rootSpanId ? this.#spans.get(this.#rootSpanId) : void 0;
root && this.#rootSpanId && (isErrorOutcome(event.event.outcome) && (root.errored = !0), root.outcome = event.event.outcome, this.#close(traceId, this.#rootSpanId, root, endMs, {
"cloudflare.outcome": event.event.outcome,
cpu_time_ms: event.event.cpuTime,
wall_time_ms: event.event.wallTime
}));
for (let [spanId, pending] of this.#spans)
pending.closed || this.#close(pending.traceId, spanId, pending, endMs, null);
this.#rootSpanId && this.#invocationBody !== null && this.#append({
traceId,
spanId: this.#rootSpanId,
tsMs: this.#startMs ?? endMs,
level: root?.errored ? "error" : "info",
message: serialize(this.#invocationBody),
operation: null
}), this.#flush(), await Promise.all(this.#writes);
}
#open(input) {
let key = rowKey(input.traceId, input.spanId);
this.#rows.set(key, input), this.#dirty.add(key), this.#maybeFlush();
}
/** Fold attributes into a buffered row, so merging costs no round-trip. */
#merge(traceId, spanId, attributes) {
let key = rowKey(traceId, spanId), row = this.#rows.get(key);
row && (row.attributes = { ...row.attributes ?? {}, ...attributes }, this.#dirty.add(key), this.#maybeFlush());
}
#append(log) {
this.#logs.push(log), this.#flush();
}
#maybeFlush() {
let buffered = this.#dirty.size + this.#logs.length;
buffered !== 0 && (buffered >= 16 || this.#latestEventMs - this.#lastFlushMs >= 100) && this.#flush();
}
/**
* Write everything buffered since the last flush. Rows stay in the map after
* flushing so a later close still carries the whole row — `persist` upserts,
* so re-sending a row is safe.
*/
#flush() {
if (this.#dirty.size === 0 && this.#logs.length === 0)
return;
let spans = [];
for (let key of this.#dirty) {
let row = this.#rows.get(key);
row && spans.push(row);
}
let logs = this.#logs;
this.#dirty.clear(), this.#logs = [], this.#lastFlushMs = this.#latestEventMs, this.#track(this.store.persist(spans, logs));
}
/** Finish a span, setting its duration and outcome and adding any final
* attributes. Runs at most once per span (guarded by `closed`). */
#close(traceId, spanId, pending, endMs, attributes) {
if (pending.closed)
return;
pending.closed = !0;
let key = rowKey(traceId, spanId), row = this.#rows.get(key);
row && (row.durationMs = Math.max(0, endMs - pending.startMs), row.outcome = pending.outcome ?? (pending.errored ? "error" : "ok"), row.error = pending.error, attributes && (row.attributes = { ...row.attributes ?? {}, ...attributes }), this.#dirty.add(key), this.#maybeFlush());
}
/** Track an in-flight store write so `outcome` can await completion. */
#track(result) {
this.#writes.push(Promise.resolve(result));
}
};
function rowKey(traceId, spanId) {
return `${traceId}\0${spanId}`;
}
function normalizeAttr(value) {
return Array.isArray(value) ? value.map((v) => typeof v == "bigint" ? bigintToJson(v) : v) : typeof value == "bigint" ? bigintToJson(value) : value;
}
function bigintToJson(value) {
return value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER ? Number(value) : value.toString();
}
function serialize(value) {
try {
return JSON.stringify(value ?? "");
} catch {
return String(value);
}
}
// src/workers/observability/trace-store.ts
import { DurableObject } from "cloudflare:workers";
var SCHEMA = [
`CREATE TABLE IF NOT EXISTS spans (
trace_id TEXT NOT NULL,
span_id TEXT NOT NULL,
parent_id TEXT,
service TEXT,
name TEXT,
kind TEXT,
start_ms INTEGER,
duration_ms INTEGER, -- whole ms; NULL while the span is still running
outcome TEXT,
error TEXT,
attributes BLOB,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (trace_id, span_id)
)`,
"CREATE INDEX IF NOT EXISTS spans_roots ON spans (start_ms) WHERE parent_id IS NULL",
`CREATE TABLE IF NOT EXISTS logs (
trace_id TEXT NOT NULL,
span_id TEXT,
seq INTEGER NOT NULL,
ts_ms INTEGER,
level TEXT,
message TEXT,
operation TEXT,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (trace_id, seq)
)`,
"CREATE INDEX IF NOT EXISTS logs_by_level ON logs (level)"
], MAX_QUERY_ROWS = 1e4, TraceStore = class extends DurableObject {
sql = this.ctx.storage.sql;
constructor(ctx, env) {
super(ctx, env), this.ctx.blockConcurrencyWhile(async () => {
for (let stmt of SCHEMA) this.sql.exec(stmt);
});
}
/** Persist one invocation's spans + logs. Called by the collector. */
persist(spans, logs) {
for (let s of spans)
this.sql.exec(
`INSERT INTO spans
(trace_id, span_id, parent_id, service, name, kind, start_ms, duration_ms, outcome, error, attributes)
VALUES (?,?,?,?,?,?,?,?,?,?, jsonb(?))
ON CONFLICT (trace_id, span_id) DO UPDATE SET
parent_id = excluded.parent_id,
service = excluded.service,
name = excluded.name,
kind = excluded.kind,
start_ms = excluded.start_ms,
duration_ms = excluded.duration_ms,
outcome = excluded.outcome,
error = excluded.error,
attributes = excluded.attributes`,
s.traceId,
s.spanId,
s.parentId,
s.service,
s.name,
s.kind,
s.startMs,
s.durationMs == null ? null : Math.round(s.durationMs),
s.outcome,
s.error,
s.attributes ? JSON.stringify(s.attributes) : null
);
let nextSeq = /* @__PURE__ */ new Map();
for (let l of logs) {
let seq = nextSeq.get(l.traceId);
if (seq === void 0) {
let row = this.sql.exec(
"SELECT COALESCE(MAX(seq), -1) + 1 AS next FROM logs WHERE trace_id = ?",
l.traceId
).one();
seq = Number(row.next);
}
this.sql.exec(
`INSERT INTO logs
(trace_id, span_id, seq, ts_ms, level, message, operation)
VALUES (?,?,?,?,?,?,?)`,
l.traceId,
l.spanId,
seq,
l.tsMs,
l.level,
l.message,
l.operation
), nextSeq.set(l.traceId, seq + 1);
}
}
/**
* Write-through capture (for long-running spans). A span is written across
* its lifetime instead of all at once on `outcome`, so the UI can show it
* in-flight: `openSpan` on start, `mergeAttributes` as they stream in, and
* `closeSpan` when it ends. `duration_ms IS NULL` marks a still-open span.
*/
/** Insert a span at open time (duration/outcome stay NULL until it closes). */
openSpan(s) {
this.sql.exec(
`INSERT INTO spans
(trace_id, span_id, parent_id, service, name, kind, start_ms, duration_ms, outcome, error, attributes)
VALUES (?,?,?,?,?,?,?,?,?,?, jsonb(?))
ON CONFLICT (trace_id, span_id) DO NOTHING`,
s.traceId,
s.spanId,
s.parentId,
s.service,
s.name,
s.kind,
s.startMs,
s.durationMs == null ? null : Math.round(s.durationMs),
s.outcome,
s.error,
s.attributes ? JSON.stringify(s.attributes) : null
);
}
/** Merge attributes onto an open span as the tail stream emits them. */
mergeAttributes(traceId, spanId, attributes) {
this.sql.exec(
`UPDATE spans
SET attributes = jsonb_patch(COALESCE(attributes, jsonb('{}')), jsonb(?))
WHERE trace_id = ? AND span_id = ?`,
JSON.stringify(attributes),
traceId,
spanId
);
}
/** Finalise a span: set duration/outcome/error and merge any final attributes. */
closeSpan(traceId, spanId, close) {
this.sql.exec(
`UPDATE spans
SET duration_ms = ?, outcome = ?, error = ?,
attributes = jsonb_patch(COALESCE(attributes, jsonb('{}')), jsonb(?))
WHERE trace_id = ? AND span_id = ?`,
Math.round(close.durationMs),
close.outcome,
close.error,
close.attributes ? JSON.stringify(close.attributes) : "{}",
traceId,
spanId
);
}
/** Append a single log, assigning the next per-trace `seq` (store-owned). */
appendLog(log) {
let { next } = this.sql.exec(
"SELECT COALESCE(MAX(seq), -1) + 1 AS next FROM logs WHERE trace_id = ?",
log.traceId
).one();
this.sql.exec(
`INSERT INTO logs
(trace_id, span_id, seq, ts_ms, level, message, operation)
VALUES (?,?,?,?,?,?,?)`,
log.traceId,
log.spanId,
Number(next),
log.tsMs,
log.level,
log.message,
log.operation
);
}
/**
* The only way to read the store: a single read-only SQL query. The
* Observability tab and coding agents both go through here (the UI has a set of
* built-in queries; agents write their own), so the `spans` and `logs` schema
* acts as the contract and is documented in the `/query` endpoint's OpenAPI
* description.
*
* Because this runs SQL we did not write, and workerd's DO SQLite has no
* read-only execution mode to rely on (`PRAGMA query_only` is rejected with
* SQLITE_AUTH), we guard it two ways. First a syntactic check: checking only
* the first keyword is not enough (`WITH … DELETE` is a single statement that
* still starts with `WITH`), so we remove comments and anything inside quotes —
* so a value like `'…delete…'` or a `;` inside a string cannot trip the checks —
* then require a single statement (no `;`) that starts with `SELECT`/`WITH` and
* contains no data- or schema-changing keyword. Second, and not relying on that
* regex, the statement runs inside a transaction that is always rolled back, so
* any write or DDL that slipped past the checks is discarded rather than
* persisted. Values are always passed as bound `params`, and at most
* `MAX_QUERY_ROWS` rows are returned.
*
* `attributes` is stored as JSONB; wrap it with `json(attributes)` to read it
* back as JSON (the built-in queries already do).
*/
query(sql, params = []) {
let statement = sql.trim().replace(/;\s*$/, ""), stripped = statement.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '""').trim();
if (!/^(SELECT|WITH)\b/i.test(stripped))
throw new Error("Only read-only SELECT/WITH queries are allowed");
if (stripped.includes(";"))
throw new Error("Only a single statement is allowed");
if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|PRAGMA|ATTACH|DETACH|VACUUM|REINDEX|ANALYZE|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|LOAD_EXTENSION)\b|\bREPLACE\s+INTO\b/i.test(
stripped
))
throw new Error("Only read-only SELECT/WITH queries are allowed");
let columns = [], rows = [], rollback = /* @__PURE__ */ Symbol("rollback");
try {
this.ctx.storage.transactionSync(() => {
let cursor = this.sql.exec(statement, ...params);
columns.push(...cursor.columnNames);
for (let row of cursor.raw()) {
if (rows.length >= MAX_QUERY_ROWS)
break;
rows.push([...row]);
}
throw rollback;
});
} catch (e) {
if (e !== rollback)
throw e;
}
return { columns, rows };
}
/** Delete all captured data. An RPC method; nothing calls it yet. */
clear() {
this.sql.exec("DELETE FROM logs"), this.sql.exec("DELETE FROM spans");
}
};
// src/workers/observability/collector.worker.ts
var LocalObservabilityCollector = class extends WorkerEntrypoint {
tailStream(onset) {
let store = this.env.TRACE_STORE.get(
this.env.TRACE_STORE.idFromName("singleton")
), worker = this.ctx.props?.worker;
return new TailToStoreHandler(store, onset, worker);
}
/**
* Reads from the trace store. A single `POST /query` runs read-only SQL against
* the `spans` and `logs` tables. The Local Explorer's Observability API
* forwards requests here, so the UI's built-in views and any coding agent all
* go through this one endpoint. The query validation lives in
* `TraceStore.query`.
*/
async fetch(request) {
let url = new URL(request.url), store = this.env.TRACE_STORE.get(
this.env.TRACE_STORE.idFromName("singleton")
);
if (url.pathname === "/query" && request.method === "POST") {
let { sql, params } = await request.json();
if (typeof sql != "string")
return Response.json({ error: "missing 'sql'" }, { status: 400 });
try {
return Response.json(await store.query(sql, params ?? []));
} catch (err) {
let message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 400 });
}
}
return url.pathname === "/clear" && request.method === "POST" ? (await store.clear(), Response.json({ success: !0 })) : new Response("not found", { status: 404 });
}
};
export {
TraceStore,
LocalObservabilityCollector as default
};
//# sourceMappingURL=collector.worker.js.map