A free workspace's projects send up to 1 GB of observability a month, held a day at a time. OTLP ingest was unmetered and needed no plan, so a free project's token could push 8 MB batches without end into its store at g1t's cost. Each project's store now counts what arrives each UTC day (a usage_days table beside the monthly usage) and remembers its workspace, which the site passes when it shows the project's Observability settings or makes a token. The telemetry worker asks billing, kept ten minutes per isolate, whether that workspace is free; if so its project may send a thirtieth of FREE_TELEMETRY_BYTES_PER_MONTH (1 GiB, a wrangler var; 0 is no cap) a day. Past it, OTLP is answered 429 with the reason until midnight UTC, and Cloudflare's tail events of the project's apps keep their requests, so the charts go on, without their logs. Counted bytes are real: an OTLP body once unzipped, as before, and for tail events the rows as they are kept, where a flat 256-byte guess per request was counted. A project whose workspace is not known yet, or a billing that does not answer, is not capped. The telemetry worker gains a BILLING binding; Settings shows what arrived today; the observability guide says so.
11 files+218−90/11 viewed
| 238 | 238 | charts reach back further than its requests do. A project's store holds up | |
| 239 | 239 | to 4 GB; past that, its oldest day goes first. | |
| 240 | 240 | ||
| 241 | + | ## On the free plan | |
| 242 | + | ||
| 243 | + | A project in a workspace on the free plan can send **1 GB a month**, held a | |
| 244 | + | day at a time: about 34 MB a day (UTC), a thirtieth of the month, so a burst | |
| 245 | + | cannot use the month up in an hour. What counts is what arrives: an OTLP | |
| 246 | + | request's body once unzipped, and the rows kept from an app's tail events. | |
| 247 | + | **Settings** shows what arrived this month and today. | |
| 248 | + | ||
| 249 | + | Past the day's share, OTLP requests are answered `429 Too Many Requests` | |
| 250 | + | with the reason, until 00:00 UTC; OpenTelemetry SDKs retry or drop the | |
| 251 | + | batch. Requests from an app on g1t.page still arrive, so its charts and | |
| 252 | + | request list keep going, but without their logs. On the g1t plan there is | |
| 253 | + | no such cap. OTLP is also limited to 600 requests a minute from one | |
| 254 | + | address; see [rate limits](/reference/rate-limits/). | |
| 255 | + | ||
| 241 | 256 | ## Who can do what | |
| 242 | 257 | ||
| 243 | 258 | | | Who | |
| 39 | 39 | ||
| 40 | 40 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 41 | 41 | const { projectId, service, project } = await observed(context, params, request, "manage_integrations"); | |
| 42 | − | const ref = { project_id: projectId }; | |
| 42 | + | // The workspace goes along, so the project's store knows whose free cap applies. | |
| 43 | + | const ref = { project_id: projectId, workspace: (params.owner ?? "").toLowerCase() }; | |
| 43 | 44 | const [settings, tokens, exports, usage] = await Promise.all([ | |
| 44 | 45 | soft(service.settings(ref), "settings"), | |
| 45 | 46 | soft(service.tokens(ref), "tokens"), | |
| ⋯ | |||
| 67 | 68 | const { projectId, service } = await observed(context, params, request, "manage_integrations"); | |
| 68 | 69 | const form = await request.formData(); | |
| 69 | 70 | const intent = String(form.get("intent") ?? ""); | |
| 70 | − | const ref = { project_id: projectId }; | |
| 71 | + | const ref = { project_id: projectId, workspace: (params.owner ?? "").toLowerCase() }; | |
| 71 | 72 | try { | |
| 72 | 73 | switch (intent) { | |
| 73 | 74 | case "retention": | |
| ⋯ | |||
| 309 | 310 | {( | |
| 310 | 311 | [ | |
| 311 | 312 | ["Received", bytes(usage.ingested_bytes)], | |
| 313 | + | ["Received today", bytes(usage.today_bytes ?? 0)], | |
| 312 | 314 | ["Requests", count(usage.requests)], | |
| 313 | 315 | ["Log lines", count(usage.logs)], | |
| 314 | 316 | ["Spans", count(usage.spans)], | |
| 319 | 319 | spans: number; | |
| 320 | 320 | metric_points: number; | |
| 321 | 321 | stored_bytes: number; | |
| 322 | + | /** Bytes received today (UTC): a workspace without the plan sends a thirtieth of its monthly observability a day. */ | |
| 323 | + | today_bytes?: number; | |
| 322 | 324 | }; | |
| 323 | 325 | ||
| 324 | 326 | /** Whom a call is for: the site and the API check access before they ask. */ | |
| 325 | − | type ProjectRef = { project_id: string }; | |
| 327 | + | type ProjectRef = { project_id: string; /** The project's workspace, by slug: the store remembers it, for a free workspace's cap. */ workspace?: string }; | |
| 326 | 328 | ||
| 327 | 329 | export interface TelemetryApi { | |
| 328 | 330 | overview(args: ProjectRef & TelemetryFilter & { step_s?: number | null }): Promise<TelemetryOverview>; |
| 23 | 23 | * project; and `GET /live?project_id=…`, a project's live tail. | |
| 24 | 24 | */ | |
| 25 | 25 | import { type RateLimitBinding, clientAddress, isLimited } from "@g1t/contracts/rate-limits"; | |
| 26 | + | import { dailyCap, freeMonthlyBytes, overCapMessage } from "./quota.ts"; | |
| 26 | 27 | import { TELEMETRY_TOKEN_PREFIX, type ErrorGroup } from "@g1t/contracts/telemetry"; | |
| 27 | 28 | ||
| 28 | 29 | import { emptyBatch, type Batch } from "./batch.ts"; | |
| ⋯ | |||
| 41 | 42 | CLOUDFLARE_EXPORT_KEY?: string; | |
| 42 | 43 | /** OTLP requests per client address (RATE_LIMITS in packages/contracts). Fails open without it. */ | |
| 43 | 44 | TELEMETRY_INGEST_LIMIT?: RateLimitBinding; | |
| 45 | + | /** Whether a project's workspace has the plan, for the free cap (./quota.ts). Without it, no cap. */ | |
| 46 | + | BILLING?: { fetch: typeof fetch }; | |
| 47 | + | /** A free workspace's project's observability a month, in bytes (./quota.ts); 0 is no cap. */ | |
| 48 | + | FREE_TELEMETRY_BYTES_PER_MONTH?: string; | |
| 44 | 49 | }; | |
| 45 | 50 | ||
| 51 | + | /** Each project's daily cap (null: none), kept ten minutes in this isolate. */ | |
| 52 | + | const caps = new Map<string, { at: number; cap: number | null }>(); | |
| 53 | + | const CAP_TTL_MS = 10 * 60_000; | |
| 54 | + | ||
| 55 | + | /** | |
| 56 | + | * The day's share a project may send, or null: only for a project whose | |
| 57 | + | * workspace (as the site last told its store) is on the free plan. A | |
| 58 | + | * project whose workspace is not known yet, or a billing that does not | |
| 59 | + | * answer, gets none, so nobody's telemetry stops on g1t's account. | |
| 60 | + | */ | |
| 61 | + | async function capFor(env: Env, projectId: string): Promise<number | null> { | |
| 62 | + | const cached = caps.get(projectId); | |
| 63 | + | if (cached && Date.now() - cached.at < CAP_TTL_MS) return cached.cap; | |
| 64 | + | let cap: number | null = null; | |
| 65 | + | const daily = dailyCap(freeMonthlyBytes(env.FREE_TELEMETRY_BYTES_PER_MONTH)); | |
| 66 | + | if (daily && env.BILLING) { | |
| 67 | + | try { | |
| 68 | + | const workspace = await store(env, projectId).workspaceOf(); | |
| 69 | + | if (workspace) { | |
| 70 | + | const { billingClient } = await import("@g1t/contracts"); | |
| 71 | + | const free = await billingClient(env.BILLING as never).freeWorkspaces([workspace]); | |
| 72 | + | if (free.map((slug) => slug.toLowerCase()).includes(workspace)) cap = daily; | |
| 73 | + | } | |
| 74 | + | } catch (error) { | |
| 75 | + | console.error("could not tell whether the project's workspace is free", projectId, error); | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | caps.set(projectId, { at: Date.now(), cap }); | |
| 79 | + | if (caps.size > 5000) caps.delete(caps.keys().next().value!); | |
| 80 | + | return cap; | |
| 81 | + | } | |
| 82 | + | ||
| 46 | 83 | const DEPLOYMENTS_DOMAIN = "g1t.page"; | |
| 47 | 84 | ||
| 48 | 85 | /** | |
| ⋯ | |||
| 114 | 151 | byProject.set(project, into); | |
| 115 | 152 | } | |
| 116 | 153 | await Promise.all( | |
| 117 | − | [...byProject].map(([project, { batch, halves }]) => | |
| 154 | + | [...byProject].map(async ([project, { batch, halves }]) => | |
| 118 | 155 | store(env, project) | |
| 119 | − | .ingest(batch, halves) | |
| 156 | + | .ingest(batch, halves, undefined, await capFor(env, project)) | |
| 120 | 157 | .catch((error: unknown) => console.error("could not keep tail events", project, error)), | |
| 121 | 158 | ), | |
| 122 | 159 | ); | |
| ⋯ | |||
| 150 | 187 | if (error instanceof IngestError) return refused(error.status, error.message); | |
| 151 | 188 | throw error; | |
| 152 | 189 | } | |
| 153 | − | const done = await store(env, project).ingest(read.batch, [], token); | |
| 190 | + | const cap = await capFor(env, project); | |
| 191 | + | const done = await store(env, project).ingest(read.batch, [], token, cap); | |
| 192 | + | if (!done.accepted && done.reason === "quota" && cap) return refused(429, overCapMessage(cap)); | |
| 154 | 193 | if (!done.accepted) return refused(401, "That token is not one of the project's, or it was revoked."); | |
| 155 | 194 | return accepted(read.protobuf); | |
| 156 | 195 | } | |
| ⋯ | |||
| 202 | 241 | const projectId = String(args.project_id ?? ""); | |
| 203 | 242 | if (!projectId) throw new IngestError(400, "No project_id."); | |
| 204 | 243 | const s = store(env, projectId); | |
| 244 | + | // The site says which workspace the project is in when it shows its | |
| 245 | + | // settings and makes a token: the store keeps it, for the free cap. | |
| 246 | + | if (typeof args.workspace === "string" && (method === "settings" || method === "create_token" || method === "usage")) { | |
| 247 | + | await s.setWorkspace(args.workspace); | |
| 248 | + | caps.delete(projectId); | |
| 249 | + | } | |
| 205 | 250 | switch (method) { | |
| 206 | 251 | case "overview": | |
| 207 | 252 | return s.overview(args); | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { DEFAULT_FREE_TELEMETRY_BYTES_PER_MONTH, dailyCap, freeMonthlyBytes, overCap, overCapMessage, sizeOf } from "./quota.ts"; | |
| 5 | + | ||
| 6 | + | test("a free workspace's project sends a thirtieth of 1 GB a day", () => { | |
| 7 | + | assert.equal(freeMonthlyBytes(undefined), DEFAULT_FREE_TELEMETRY_BYTES_PER_MONTH); | |
| 8 | + | assert.equal(freeMonthlyBytes(" "), DEFAULT_FREE_TELEMETRY_BYTES_PER_MONTH); | |
| 9 | + | assert.equal(freeMonthlyBytes("3000"), 3000); | |
| 10 | + | assert.equal(freeMonthlyBytes("0"), 0); | |
| 11 | + | assert.equal(dailyCap(3000), 100); | |
| 12 | + | assert.equal(dailyCap(0), null); | |
| 13 | + | assert.equal(dailyCap(DEFAULT_FREE_TELEMETRY_BYTES_PER_MONTH), 35_791_395); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("past the day's share is over, and no cap never is", () => { | |
| 17 | + | assert.equal(overCap(90, 10, 100), false); | |
| 18 | + | assert.equal(overCap(90, 11, 100), true); | |
| 19 | + | assert.equal(overCap(1e12, 1e12, null), false); | |
| 20 | + | assert.match(overCapMessage(35_791_395), /its 34 MB for today/); | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | test("a row counts as what is kept of it", () => { | |
| 24 | + | assert.equal(sizeOf({ body: "hello" }), '{"body":"hello"}'.length); | |
| 25 | + | assert.equal(sizeOf(undefined), 0); | |
| 26 | + | }); |
| 1 | + | /** | |
| 2 | + | * What a project of a workspace without the g1t plan may send: | |
| 3 | + | * `FREE_TELEMETRY_BYTES_PER_MONTH` (1 GiB, a wrangler var; `0` is no cap), | |
| 4 | + | * held a day at a time as a thirtieth of it, so a burst cannot spend the | |
| 5 | + | * month in an hour. Past a day's share, OTLP is answered `429` until | |
| 6 | + | * midnight UTC, and Cloudflare's tail events of the project's apps keep | |
| 7 | + | * their requests (what the charts are counted from) without their logs. | |
| 8 | + | * | |
| 9 | + | * Bytes are what arrived: an OTLP body once unzipped, and a tail event's | |
| 10 | + | * rows as they are kept. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | export const DEFAULT_FREE_TELEMETRY_BYTES_PER_MONTH = 1024 * 1024 * 1024; | |
| 14 | + | const DAYS_PER_MONTH = 30; | |
| 15 | + | ||
| 16 | + | /** The monthly allowance from its var: the default when unset or not a whole number; 0 is none. */ | |
| 17 | + | export function freeMonthlyBytes(raw: string | undefined): number { | |
| 18 | + | const text = (raw ?? "").trim(); | |
| 19 | + | const value = Number(text); | |
| 20 | + | return text !== "" && Number.isInteger(value) && value >= 0 ? value : DEFAULT_FREE_TELEMETRY_BYTES_PER_MONTH; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /** A day's share of a monthly allowance; null for none. */ | |
| 24 | + | export function dailyCap(monthly: number): number | null { | |
| 25 | + | return monthly > 0 ? Math.ceil(monthly / DAYS_PER_MONTH) : null; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /** Whether `adding` more bytes today would go past `cap`. */ | |
| 29 | + | export function overCap(today: number, adding: number, cap: number | null | undefined): boolean { | |
| 30 | + | return cap != null && cap > 0 && today + adding > cap; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** The size of a row as it is kept: its JSON. */ | |
| 34 | + | export function sizeOf(value: unknown): number { | |
| 35 | + | return JSON.stringify(value)?.length ?? 0; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** What OTLP past the day's share is told. */ | |
| 39 | + | export function overCapMessage(cap: number): string { | |
| 40 | + | const mb = Math.round(cap / (1024 * 1024)); | |
| 41 | + | return `This project has sent its ${mb} MB for today, a workspace on the free plan's daily share of its monthly observability. Send less, or start the g1t plan; more is taken from 00:00 UTC.`; | |
| 42 | + | } |
| 191 | 191 | metric_points INTEGER NOT NULL DEFAULT 0 | |
| 192 | 192 | ); | |
| 193 | 193 | `, | |
| 194 | + | // What arrived each day (UTC), for a free workspace's daily share | |
| 195 | + | // (../quota.ts). Kept a little over a month. | |
| 196 | + | ` | |
| 197 | + | CREATE TABLE usage_days ( | |
| 198 | + | day TEXT PRIMARY KEY, | |
| 199 | + | ingested_bytes INTEGER NOT NULL DEFAULT 0 | |
| 200 | + | ); | |
| 201 | + | `, | |
| 194 | 202 | ]; | |
| 195 | 203 | ||
| 196 | 204 | /** |
| 312 | 312 | assert.equal(stepFor(86_400_000_000), 900); | |
| 313 | 313 | assert.equal(aggregate([]).length, 0); | |
| 314 | 314 | }); | |
| 315 | + | ||
| 316 | + | test("a free workspace's project is refused past its day's share, and tail events keep only their requests", async () => { | |
| 317 | + | const store = new Store(state()); | |
| 318 | + | const { secret } = await store.createToken("prj_1", "ci", "u1"); | |
| 319 | + | const batch = (bytes: number) => ({ ...emptyBatch(), bytes }); | |
| 320 | + | assert.equal((await store.ingest(batch(60), [], secret, 100)).accepted, true); | |
| 321 | + | assert.equal(store.todayBytes(), 60); | |
| 322 | + | const refused = await store.ingest(batch(50), [], secret, 100); | |
| 323 | + | assert.deepEqual(refused, { accepted: false, reason: "quota" }); | |
| 324 | + | assert.equal(store.todayBytes(), 60, "nothing refused is counted"); | |
| 325 | + | // Without a cap (the plan, or a workspace not known yet) nothing is refused. | |
| 326 | + | assert.equal((await store.ingest(batch(5000), [], secret, null)).accepted, true); | |
| 327 | + | // Tail events past the share keep their requests, not their logs. | |
| 328 | + | const tail = { ...emptyBatch(), bytes: 10_000 }; | |
| 329 | + | tail.logs.push({ id: "l1", time_us: 1, severity: "info", severity_number: 9, body: "x".repeat(9000), trace_id: null, span_id: null, request_id: null, attributes: {}, service: "web", environment: null, deployment_id: null, commit_sha: null, source: "cloudflare" }); | |
| 330 | + | assert.equal((await store.ingest(tail, [], undefined, 100)).accepted, true); | |
| 331 | + | assert.equal(store.logs({}).items.length, 0); | |
| 332 | + | assert.equal(store.usage().today_bytes, store.todayBytes()); | |
| 333 | + | // The workspace is remembered as the site says it. | |
| 334 | + | assert.equal(store.workspaceOf(), null); | |
| 335 | + | store.setWorkspace(" Acme "); | |
| 336 | + | assert.equal(store.workspaceOf(), "acme"); | |
| 337 | + | store.setWorkspace("not a slug!"); | |
| 338 | + | assert.equal(store.workspaceOf(), "acme"); | |
| 339 | + | }); |
| 46 | 46 | import { exportEndpointProblem, otlpBodies, send } from "./export.ts"; | |
| 47 | 47 | import { addInto, bucketOf, decode, emptyHistogram, encode, percentiles } from "./histogram.ts"; | |
| 48 | 48 | import { FTS, MIGRATIONS, ROLLUP_DAYS } from "./schema.ts"; | |
| 49 | + | import { overCap, sizeOf } from "./quota.ts"; | |
| 49 | 50 | import { SEVERITY_FLOOR, randomId, severityOf } from "./shape.ts"; | |
| 50 | 51 | import type { RequestHalf } from "./tail.ts"; | |
| 51 | 52 | ||
| ⋯ | |||
| 81 | 82 | return new Date(us / 1000).toISOString().slice(0, 7); | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 85 | + | /** `2026-10-12`: the UTC day, for a free workspace's daily share. */ | |
| 86 | + | function day(us = Date.now() * 1000): string { | |
| 87 | + | return new Date(us / 1000).toISOString().slice(0, 10); | |
| 88 | + | } | |
| 89 | + | ||
| 84 | 90 | /** A page's cursor: the last row's time and id, so the next page starts after it. */ | |
| 85 | 91 | function cursorOf(time: number, id: string): string { | |
| 86 | 92 | return btoa(`${time}:${id}`); | |
| ⋯ | |||
| 179 | 185 | return this.settings(); | |
| 180 | 186 | } | |
| 181 | 187 | ||
| 188 | + | /** The workspace the project is in, by slug, as the site last said; null before it has. */ | |
| 189 | + | workspaceOf(): string | null { | |
| 190 | + | const row = this.rows("SELECT value FROM settings WHERE key = 'workspace'")[0]; | |
| 191 | + | return row ? String(row.value) : null; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | setWorkspace(slug: string): void { | |
| 195 | + | const clean = slug.trim().toLowerCase(); | |
| 196 | + | if (!/^[a-z0-9][a-z0-9-]{0,38}$/.test(clean) || clean === this.workspaceOf()) return; | |
| 197 | + | this.sql.exec("INSERT INTO settings (key, value) VALUES ('workspace', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", clean); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /** Bytes that arrived today (UTC). */ | |
| 201 | + | todayBytes(): number { | |
| 202 | + | return Number(this.rows("SELECT ingested_bytes FROM usage_days WHERE day = ?", day())[0]?.ingested_bytes ?? 0); | |
| 203 | + | } | |
| 204 | + | ||
| 182 | 205 | // ---- Tokens ------------------------------------------------------------- | |
| 183 | 206 | ||
| 184 | 207 | tokenRow(row: Row): IngestToken { | |
| ⋯ | |||
| 249 | 272 | * Adds what arrived. `token` is checked first when given (the OTLP door); | |
| 250 | 273 | * tail events come from Cloudflare and need none. Returns false for a | |
| 251 | 274 | * token that is not this project's, or when this kind of telemetry is off. | |
| 275 | + | * | |
| 276 | + | * `dailyCap`, for a project of a workspace without the plan, is the | |
| 277 | + | * day's share of its free observability (../quota.ts): past it, OTLP is | |
| 278 | + | * refused (`quota`), and tail events keep their requests without logs. | |
| 252 | 279 | */ | |
| 253 | − | async ingest(batch: Batch, halves: RequestHalf[] = [], token?: string): Promise<{ accepted: boolean; reason?: string }> { | |
| 280 | + | async ingest(batch: Batch, halves: RequestHalf[] = [], token?: string, dailyCap?: number | null): Promise<{ accepted: boolean; reason?: string }> { | |
| 254 | 281 | if (token !== undefined && !(await this.checkToken(token))) return { accepted: false, reason: "token" }; | |
| 255 | 282 | if (token === undefined && !this.settings().deployments) return { accepted: false, reason: "off" }; | |
| 283 | + | if (overCap(this.todayBytes(), batch.bytes, dailyCap)) { | |
| 284 | + | if (token !== undefined) return { accepted: false, reason: "quota" }; | |
| 285 | + | batch = { ...batch, logs: [], spans: [], metrics: [], errors: [], bytes: sizeOf(halves) + sizeOf(batch.requests) }; | |
| 286 | + | } | |
| 256 | 287 | ||
| 257 | 288 | const live: LiveItem[] = []; | |
| 258 | 289 | for (const h of halves) this.putHalf(h); | |
| ⋯ | |||
| 305 | 336 | logs = logs + excluded.logs, spans = spans + excluded.spans, metric_points = metric_points + excluded.metric_points`, | |
| 306 | 337 | month(), batch.bytes, requestCount, batch.logs.length, batch.spans.length, batch.metrics.length, | |
| 307 | 338 | ); | |
| 339 | + | this.sql.exec( | |
| 340 | + | "INSERT INTO usage_days (day, ingested_bytes) VALUES (?, ?) ON CONFLICT (day) DO UPDATE SET ingested_bytes = ingested_bytes + excluded.ingested_bytes", | |
| 341 | + | day(), batch.bytes, | |
| 342 | + | ); | |
| 308 | 343 | ||
| 309 | 344 | // Requests go live (and to exports) whole: once both halves are in, or | |
| 310 | 345 | // for OTLP, at once. | |
| ⋯ | |||
| 533 | 568 | this.sql.exec("DELETE FROM metric_points WHERE time_us < ?", keep); | |
| 534 | 569 | this.sql.exec("DELETE FROM rollups WHERE minute < ?", Math.floor((now - ROLLUP_DAYS * DAY_US) / MINUTE_US)); | |
| 535 | 570 | this.sql.exec("DELETE FROM error_groups WHERE last_us < ? AND issue_number IS NULL", now - ROLLUP_DAYS * DAY_US); | |
| 571 | + | this.sql.exec("DELETE FROM usage_days WHERE day < ?", day(now - 40 * DAY_US)); | |
| 536 | 572 | // Over its size, the store sheds a day at a time from the oldest. | |
| 537 | 573 | for (let i = 0; i < 30 && this.state.storage.sql.databaseSize > MAX_BYTES; i++) { | |
| 538 | 574 | const oldest = Number(this.rows("SELECT MIN(time_us) AS t FROM logs")[0]?.t ?? now); | |
| ⋯ | |||
| 950 | 986 | spans: Number(row?.spans ?? 0), | |
| 951 | 987 | metric_points: Number(row?.metric_points ?? 0), | |
| 952 | 988 | stored_bytes: this.state.storage.sql.databaseSize, | |
| 989 | + | today_bytes: this.todayBytes(), | |
| 953 | 990 | }; | |
| 954 | 991 | } | |
| 955 | 992 | ||
| 23 | 23 | import { TELEMETRY_TAG, type Attributes, type TelemetryLog, type TelemetryRequest } from "@g1t/contracts/telemetry"; | |
| 24 | 24 | ||
| 25 | 25 | import { emptyBatch, type Batch } from "./batch.ts"; | |
| 26 | + | import { sizeOf } from "./quota.ts"; | |
| 26 | 27 | import { clip, hostPath, randomId, route, severityNumber, severityOf, text } from "./shape.ts"; | |
| 27 | 28 | ||
| 28 | 29 | /** The first argument of the dispatcher's line for each request. */ | |
| ⋯ | |||
| 144 | 145 | country: cf.country ?? null, | |
| 145 | 146 | trace_id: mark.trace_id || null, | |
| 146 | 147 | }); | |
| 147 | − | g.batch.bytes += 256; | |
| 148 | + | g.batch.bytes += sizeOf(g.halves[g.halves.length - 1]); | |
| 148 | 149 | continue; | |
| 149 | 150 | } | |
| 150 | 151 | ||
| ⋯ | |||
| 228 | 229 | log_count: logs.length, | |
| 229 | 230 | error: first ? { name: first.name, message: clip(first.message, 2048) } : item.outcome !== "ok" && item.outcome !== "canceled" ? { name: item.outcome, message: outcomeText(item.outcome) } : null, | |
| 230 | 231 | }); | |
| 231 | − | g.batch.bytes += logs.reduce((n, l) => n + l.body.length, 256); | |
| 232 | + | g.batch.bytes += sizeOf(g.halves[g.halves.length - 1]) + logs.reduce((n, l) => n + sizeOf(l), 0); | |
| 232 | 233 | } | |
| 233 | 234 | return [...groups.values()]; | |
| 234 | 235 | } | |
| 21 | 21 | // project and deployment), read to file a request the dispatcher saw | |
| 22 | 22 | // before its note reached it, and Cloudflare's own export. Read only. | |
| 23 | 23 | "kv_namespaces": [{ "binding": "DOMAINS", "id": "14bc5c233d4c46a5bbf23b5367cce5fd" }], | |
| 24 | + | // Whether a project's workspace has the plan: a free one's projects send | |
| 25 | + | // FREE_TELEMETRY_BYTES_PER_MONTH (1 GiB) a month, a thirtieth a day; past | |
| 26 | + | // it OTLP is answered 429 and tail events keep their requests only | |
| 27 | + | // (src/quota.ts). 0 is no cap. | |
| 28 | + | "services": [{ "binding": "BILLING", "service": "g1t-billing" }], | |
| 29 | + | "vars": { "FREE_TELEMETRY_BYTES_PER_MONTH": "1073741824" }, | |
| 24 | 30 | // Secret, optional: CLOUDFLARE_EXPORT_KEY, which a Workers | |
| 25 | 31 | // Observability destination on the account sends as x-g1t-export-key | |
| 26 | 32 | // to https://otel.g1t.sh/cloudflare/v1/{traces,logs}. |
Comments
Sign in to comment.