import * as workers from "@distilled.cloud/cloudflare/workers";
import type * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Queue from "effect/Queue";
import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
import * as Socket from "effect/unstable/socket/Socket";
import type { LogLine, LogsInput } from "../Provider.ts";
const DEFAULT_LOOKBACK_MS = 1 * 60 * 60 * 1000;
export interface TelemetryFilter {
key: string;
operation:
| "eq"
| "neq"
| "includes"
| "not_includes"
| "starts_with"
| "gt"
| "gte"
| "lt"
| "lte"
| "in"
| "not_in";
type: "string" | "number" | "boolean";
value?: string | number | boolean;
}
interface TailEventMessage {
eventTimestamp?: number;
wallTime: number;
cpuTime: number;
truncated: boolean;
outcome: string;
scriptName: string;
exceptions: {
name: string;
message: string;
stack: string;
timestamp: string;
}[];
logs: {
message: string[];
level: string;
timestamp: string;
}[];
event:
| {
request: { method: string; url: string };
response?: { status: number };
}
| null
| undefined;
}
const parseEvents = (
response: workers.QueryObservabilityTelemetryResponse,
): LogLine[] => {
const lines: LogLine[] = [];
if (response.events?.events) {
for (const event of response.events.events) {
const ts = new Date(event.timestamp);
const meta = event.metadata;
const msg =
meta.message ??
(meta.level === "error"
? `error: ${meta.error ?? "unknown"}`
: `${meta.level ?? "log"}`);
lines.push({ timestamp: ts, message: msg });
}
}
return lines;
};
export const CloudflareLogs = Effect.gen(function* () {
const queryTelemetry = yield* workers.queryObservabilityTelemetry;
const createScriptTail = yield* workers.createScriptTail;
const deleteScriptTail = yield* workers.deleteScriptTail;
/**
* The telemetry query needs the `workers_observability:read` OAuth scope.
* A token minted by an older `alchemy login` (before the scope joined the
* defaults) keeps its original grants forever, so the query fails with a
* bare `Unauthorized`/`Forbidden` even though a fresh login would work —
* explain the fix instead of surfacing the raw tag.
*/
const explainMissingObservabilityScope = (
effect: Effect.Effect,
): Effect.Effect =>
effect.pipe(
Effect.catchTag("Unauthorized", () =>
Effect.die(
new Error(
"Cloudflare rejected the observability telemetry query (Unauthorized). " +
'Your stored credentials are likely missing the "workers_observability:read" scope — ' +
"OAuth tokens keep the scopes they were minted with, so tokens from an older " +
"`alchemy login` won't have it. Run `alchemy login` again to mint a token with " +
"the current default scopes, or use an API token that grants Workers Observability read access.",
),
),
),
);
const queryLogs = (opts: {
accountId: string;
filters: TelemetryFilter[];
options: LogsInput;
}) =>
Effect.gen(function* () {
const now = Date.now();
const limit = opts.options.limit ?? 100;
if (opts.options.since) {
const response = yield* explainMissingObservabilityScope(
queryTelemetry({
accountId: opts.accountId,
queryId: "events",
view: "events",
timeframe: { from: opts.options.since.getTime(), to: now },
limit,
parameters: {
filters: opts.filters,
// orderBy: { value: "timestamp", order: "desc" },
},
}),
);
return parseEvents(response);
}
const response = yield* explainMissingObservabilityScope(
queryTelemetry({
accountId: opts.accountId,
queryId: "events",
view: "events",
timeframe: { from: now - DEFAULT_LOOKBACK_MS, to: now },
limit,
parameters: {
filters: opts.filters,
},
}),
);
return parseEvents(response);
});
/**
* Open a real-time Workers tail session against `scriptName` and
* surface its messages as a {@link LogLine} stream. The websocket is
* automatically reconnected (with `Stream.repeat`) when Cloudflare
* closes it after its idle window.
*/
const tailScript = (opts: { accountId: string; scriptName: string }) => {
const runTailSession = Effect.gen(function* () {
const { id: tailId, url } = yield* createScriptTail({
scriptName: opts.scriptName,
accountId: opts.accountId,
filters: [],
});
const socket = yield* Socket.makeWebSocket(url, {
protocols: ["trace-v1"],
});
const queue = yield* Queue.make();
yield* socket
.runRaw((raw) => {
const text =
typeof raw === "string" ? raw : new TextDecoder().decode(raw);
const data: TailEventMessage = JSON.parse(text);
const eventTs = new Date(data.eventTimestamp ?? Date.now());
if (data.event && "request" in data.event) {
const reqEvent = data.event;
const pathname = (() => {
try {
return new URL(reqEvent.request.url).pathname;
} catch {
return reqEvent.request.url;
}
})();
const status = reqEvent.response?.status ?? 500;
Queue.offerUnsafe(queue, {
timestamp: eventTs,
message: `${reqEvent.request.method} ${pathname} > ${status} (cpu: ${Math.round(data.cpuTime)}ms, wall: ${Math.round(data.wallTime)}ms)`,
});
}
for (const log of data.logs) {
const msg = log.message.join(" ");
Queue.offerUnsafe(queue, {
timestamp: new Date(log.timestamp),
message: log.level === "log" ? msg : `${log.level}: ${msg}`,
});
}
for (const exception of data.exceptions) {
Queue.offerUnsafe(queue, {
timestamp: new Date(exception.timestamp),
message: `${exception.name} ${exception.message}\n${exception.stack}`,
});
}
})
.pipe(
Effect.ensuring(
Effect.all([
deleteScriptTail({
scriptName: opts.scriptName,
id: tailId,
accountId: opts.accountId,
}).pipe(Effect.ignore),
Queue.end(queue),
]),
),
Effect.ignore,
Effect.forkChild(),
);
return Stream.fromQueue(queue);
});
return Stream.unwrap(runTailSession).pipe(
Stream.repeat(Schedule.spaced("1 second")),
);
};
const tailStream = (opts: {
accountId: string;
filters: TelemetryFilter[];
}) => {
const poll = (since: number): Stream.Stream =>
Stream.unwrap(
Effect.gen(function* () {
yield* Effect.sleep("2 seconds");
const now = Date.now();
const response = yield* explainMissingObservabilityScope(
queryTelemetry({
accountId: opts.accountId,
queryId: "events",
view: "events",
timeframe: { from: since, to: now },
limit: 100,
parameters: {
filters: opts.filters,
orderBy: { value: "timestamp", order: "asc" },
},
}),
);
const lines = parseEvents(response);
const nextSince =
lines.length > 0
? Math.max(...lines.map((l) => l.timestamp.getTime())) + 1
: since;
return Stream.concat(Stream.fromIterable(lines), poll(nextSince));
}),
);
return poll(Date.now());
};
return { queryLogs, tailScript, tailStream };
});