Skip to content

Commit 9763b4a

Browse files
committed
Keep a span's created_at at its start when re-persisting
Batched writes re-send a span row on every flush it's dirty for (open, attribute merges, close), and `persist` used INSERT OR REPLACE, which deletes the row before re-inserting. That re-stamped the `created_at` default with the time of the latest flush. The trace list renders the root span's `created_at`, and the root is re-sent when the invocation closes, so the displayed time showed when a request finished rather than when it started. Upsert instead, leaving `created_at` alone — matching what the write-through path did with INSERT ... DO NOTHING followed by UPDATE. Also correct the FLUSH_INTERVAL_MS comment: closing spans are buffered like any other row, so only logs and exceptions are written as they arrive.
1 parent b88fe5d commit 9763b4a

3 files changed

Lines changed: 81 additions & 4 deletions

File tree

packages/miniflare/src/workers/observability/tail-to-store.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ const FLUSH_THRESHOLD = 16;
3838
* Time comes from tail-event timestamps, not `Date.now()`, which a Worker only
3939
* advances on I/O. So this bounds staleness *between events*, not in wall-clock
4040
* time: an invocation that goes completely quiet flushes nothing further until
41-
* its outcome. Logs and closing spans are written as they happen, which is what
42-
* covers the quiet case in practice.
41+
* its outcome. Logs and exceptions are written as they arrive, so a quiet
42+
* invocation can still report what it's doing; a closing span is buffered like
43+
* any other row, so its duration can trail the close event by one flush.
4344
*/
4445
const FLUSH_INTERVAL_MS = 100;
4546

packages/miniflare/src/workers/observability/trace-store.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,25 @@ export class TraceStore extends DurableObject {
103103
/** Persist one invocation's spans + logs. Called by the collector. */
104104
persist(spans: SpanInput[], logs: LogInput[]): void {
105105
for (const s of spans) {
106+
// Upsert rather than INSERT OR REPLACE: a span is re-sent on every flush
107+
// it's dirty for (open, attribute merges, close), and REPLACE deletes the
108+
// row first, so `created_at` would be re-stamped with the latest flush.
109+
// The trace list renders the root span's `created_at`, which would then
110+
// show when the invocation finished rather than when it started.
106111
this.sql.exec(
107-
`INSERT OR REPLACE INTO spans
112+
`INSERT INTO spans
108113
(trace_id, span_id, parent_id, service, name, kind, start_ms, duration_ms, outcome, error, attributes)
109-
VALUES (?,?,?,?,?,?,?,?,?,?, jsonb(?))`,
114+
VALUES (?,?,?,?,?,?,?,?,?,?, jsonb(?))
115+
ON CONFLICT (trace_id, span_id) DO UPDATE SET
116+
parent_id = excluded.parent_id,
117+
service = excluded.service,
118+
name = excluded.name,
119+
kind = excluded.kind,
120+
start_ms = excluded.start_ms,
121+
duration_ms = excluded.duration_ms,
122+
outcome = excluded.outcome,
123+
error = excluded.error,
124+
attributes = excluded.attributes`,
110125
s.traceId,
111126
s.spanId,
112127
s.parentId,

packages/miniflare/test/plugins/core/observability.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,19 @@ export default {
179179
});
180180
return new Response("closed");
181181
}
182+
if (url.pathname === "/repersist") {
183+
const store = env.TRACE_STORE.get(env.TRACE_STORE.idFromName("singleton"));
184+
// The same span re-sent as a later batch flush would send it: still
185+
// running on the first call, closed on the second.
186+
await store.persist([{
187+
traceId: "trace-re", spanId: "root", parentId: null,
188+
name: "agent run", kind: "http", startMs: 7000,
189+
durationMs: url.searchParams.get("close") ? 2500 : null,
190+
outcome: url.searchParams.get("close") ? "ok" : null,
191+
error: null, attributes: { "faas.trigger": "http" },
192+
}], []);
193+
return new Response("repersisted");
194+
}
182195
if (url.pathname.startsWith("/wobs/")) {
183196
return env.WOBS.fetch(
184197
new Request("http://collector" + url.pathname.slice("/wobs".length) + url.search, request)
@@ -370,6 +383,54 @@ describe("unsafeObservability (write-through capture)", () => {
370383
expect(rootClosed.duration_ms).toBe(1234);
371384
expect(JSON.parse(rootClosed.attributes ?? "{}")["cpu_time_ms"]).toBe(2);
372385
});
386+
387+
test("re-persisting a span updates it without re-stamping created_at", async ({
388+
expect,
389+
}) => {
390+
const mf = new Miniflare({
391+
unsafeObservability: true,
392+
workers: [storeWorker()],
393+
});
394+
useDispose(mf);
395+
396+
async function readRe() {
397+
const rows = await queryStore(
398+
mf,
399+
`SELECT duration_ms, outcome, created_at FROM spans
400+
WHERE trace_id = ? AND span_id = 'root'`,
401+
["trace-re"]
402+
);
403+
assert(rows[0], "expected the re-persisted span");
404+
return rows[0] as {
405+
duration_ms: number | null;
406+
outcome: string | null;
407+
created_at: string;
408+
};
409+
}
410+
411+
expect(
412+
await (await mf.dispatchFetch("http://localhost/repersist")).text()
413+
).toBe("repersisted");
414+
const open = await readRe();
415+
expect(open.duration_ms).toBe(null);
416+
417+
// `created_at` defaults to `datetime('now')`, which has whole-second
418+
// granularity, so wait long enough that a re-stamp would be visible.
419+
await new Promise((resolve) => setTimeout(resolve, 1100));
420+
421+
expect(
422+
await (
423+
await mf.dispatchFetch("http://localhost/repersist?close=1")
424+
).text()
425+
).toBe("repersisted");
426+
const closed = await readRe();
427+
// The update landed...
428+
expect(closed.duration_ms).toBe(2500);
429+
expect(closed.outcome).toBe("ok");
430+
// ...but the row was upserted, not deleted and re-inserted, so the trace
431+
// list still shows when the invocation started rather than when it ended.
432+
expect(closed.created_at).toBe(open.created_at);
433+
});
373434
});
374435

375436
// A plain user worker (no manual seeding): it does some work, logs, and proxies

0 commit comments

Comments
 (0)