cail-log is CAIL's pre-release operational event primitive. It emits small,
privacy-constrained lifecycle and diagnostic events in Cloudflare Workers,
browsers, Bun, and Node 20 or newer.
The record is aligned with the OpenTelemetry Logs Data Model and semantic conventions. It is not an OpenTelemetry SDK or an OTLP exporter. Collection, sampling, retention, export, and dashboards remain separate concerns. The package includes deterministic projections for Cloudflare Workers Logs and Workers Analytics Engine so every producer uses the same field positions; the storage products still own delivery, sampling, retention, and querying.
- Event names come from a catalog that defines one structure per event.
- Event bodies, source profiles, severity policies, required fields, and optional fields are catalog-owned rather than call arguments.
- Event fields are narrowed in TypeScript and validated again at runtime.
- Service identity and deployment environment are constructor-owned resource attributes.
- Tenant loggers cannot claim platform identity, application, project, model, cost, cohort, user, or quota facts.
- A malformed, missing, contradictory, or known-but-disallowed field drops the event with a content-free diagnostic instead of creating a weaker event.
- Unknown arbitrary keys are ignored and never become log content.
- Logging and diagnostic failures do not throw into the application path.
- Sink selection is explicit. The Cloudflare sink emits one structured, queryable JSON object; the JSON-line sink is a separate deliberate choice.
fanoutSinks()invokes every selected sink even if another fails, so a diagnostic destination cannot suppress the fleet-analytics projection.
These rules close common free-text channels. They cannot prove the semantic origin of every valid machine identifier. Trusted platform callers still have to classify values correctly and must not place personal data in fields such as model, key, cohort, or project identifiers.
Pin a reviewed commit while the package remains below 1.0.0:
bun add github:CUNY-AI-Lab/cail-log#<commit>The repository commits dist, so consumers do not need a package build step.
import {
CAIL_EVENT_CATALOG,
CAIL_EVENTS,
createCailLogger,
workersStructuredSink,
} from "@cuny-ai-lab/cail-log";
const log = createCailLogger({
service: "sandbox-bridge",
release: "218328f",
env: "production",
sourceClass: "platform",
catalog: CAIL_EVENT_CATALOG,
sink: workersStructuredSink,
});
log.emit(CAIL_EVENTS.SANDBOX_USAGE_SETTLED, {
usage_id: "8b9ec144-39aa-4f1f-bda5-4c645facf2cd",
action_id: "9f50d4a4-ef70-41b2-b225-0a5cbf2df5e7",
product_id: "kale-workbench",
principal: {
type: "user",
subject: "cail-0123456789abcdef0123456789abcdef",
},
terminal: { outcome: "ok", reason: "completed" },
usage: {
kind: "sandbox_compute",
unit: "mib_milliseconds",
quantity: 67_108_864,
},
});The catalog narrows the event name, required fields, optional fields, source
profile, and severity. An untyped unknown name emits event.invalid with the
fixed body Event name rejected. The rejected value is not echoed. Applications
may define additional events with defineEventCatalog, but each definition must
declare the same contract components; a name is never just a message string.
The cail.* namespace is reserved for the canonical library catalog so a
consumer cannot redefine a shared fleet event with a different structure.
Use extendCailEventCatalog() when one logger needs both canonical fleet events
and service-local events. Logger construction rejects catalog-shaped objects
that did not pass one of these definition functions.
| Event | Required semantic core |
|---|---|
cail.action.admitted |
action, product, principal |
cail.action.terminal |
action, product, principal, outcome/reason, duration |
cail.request.received |
request, product, HTTP method, route template |
cail.request.completed |
request, product, HTTP facts, outcome/reason, duration |
cail.auth.denied |
request, product, principal, HTTP facts, denied outcome |
cail.quota.charged |
product, principal, successful outcome, quota snapshot |
cail.upstream.error |
request, product, failed outcome, safe error type |
cail.model.call.admitted |
call, action, product, principal, provider, requested model |
cail.model.call.terminal |
admitted-call fields plus outcome/reason and duration |
cail.sandbox.usage.settled |
usage, product, principal, successful outcome, exact usage |
The exported TypeScript type is the exact field-level contract. This table is an orientation aid, not a second schema.
The portable sink receives an OpenTelemetry-aligned record:
{
"schema_version": 1,
"timestamp": "2026-07-13T16:00:00.000Z",
"severity_text": "INFO",
"severity_number": 9,
"event_name": "cail.sandbox.usage.settled",
"body": "Sandbox usage settled.",
"resource": {
"service.namespace": "cuny-ai-lab",
"service.name": "sandbox-bridge",
"service.version": "218328f",
"deployment.environment.name": "production"
},
"attributes": {
"cail.source.class": "platform",
"cail.product.id": "kale-workbench",
"cail.usage.id": "8b9ec144-39aa-4f1f-bda5-4c645facf2cd",
"cail.usage.kind": "sandbox_compute",
"cail.usage.unit": "mib_milliseconds",
"cail.usage.quantity": 67108864
}
}severity_number uses the OpenTelemetry bands 1, 5, 9, 13, 17, and
21 for trace through fatal. Static severity is catalog-owned. Outcome events
use one closed mapping: success and cancellation are INFO; client error,
denial, and unknown outcome are WARN; error and timeout are ERROR. Attribute
values are scalar strings, numbers, or booleans. Nested application objects and
arbitrary content are not accepted.
workersStructuredSink projects this record into one flat JSON object. For
example, resource["service.name"] becomes the top-level key service.name.
Cloudflare Workers Logs can then filter, group, and aggregate those fields
without making Cloudflare's storage format the portable package contract.
This sink constrains custom console events only. Cloudflare separately creates
invocation logs, which can contain request URL and response metadata. A
production pilot must either set observability.logs.invocation_logs to
false or explicitly approve the native fields, retention, access, and
purpose. The choice belongs in deployment configuration, not this package.
Cloudflare Workers Logs is a short-lived diagnostic surface and does not expose
a programmatic query API for the fleet console's aggregate trends. The optional
Analytics Engine projection writes the same accepted event to the versioned
cail_fleet_events_v1 dataset:
import {
createAnalyticsEngineSink,
fanoutSinks,
workersStructuredSink,
} from "@cuny-ai-lab/cail-log";
const sink = fanoutSinks(
workersStructuredSink,
createAnalyticsEngineSink(env.CAIL_FLEET_EVENTS),
);toAnalyticsEngineDataPoint() owns the complete ordered-column projection.
CAIL_ANALYTICS_ENGINE_BLOBS and CAIL_ANALYTICS_ENGINE_DOUBLES publish the
one-based positions used by queries. Missing strings are empty; missing
nonnegative numeric facts use CAIL_ANALYTICS_ENGINE_MISSING_NUMBER (-1), so
zero never means unknown. The point index is deployment environment plus
trusted product_id, with a namespaced service fallback for service-local
events. This prevents noisy test or staging traffic from sharing a production
sampling boundary.
The fleet projection intentionally omits quota values, stable user pseudonyms, per-event UUIDs, usage facts, and Kale tenant-project identity. Model-limit state and Sandbox allocation come from their authoritative accounting APIs; identifiable user reads are audited and resolved there on demand. Kale tenant state comes from its control plane. The aggregate projection retains the privacy-safer cohort. Blob positions 16–20 and double positions 14–20 are reserved for append-only schema growth.
Analytics Engine is diagnostic only. It may sample, retains data for its native
platform window, and cannot replace authoritative product state, model
accounting, or Sandbox accounting. Weighted aggregate success/error/latency
queries use _sample_interval and expose sampling evidence. Exact lifecycle
pairing, duplicates, missing terminals, and individual event sequences require
a product-owned durable state store; Analytics Engine cannot prove them even
when the observed sample interval is one.
The adapter writes one point for each accepted event. Cloudflare currently
allows 250 Analytics Engine points per Worker invocation; the exported
CAIL_ANALYTICS_ENGINE_MAX_POINTS_PER_INVOCATION constant lets producer
adapters enforce that platform ceiling. Canonical producers emit a bounded
number of lifecycle events per invocation and must not use cail-log as a
bulk-event transport.
Callers use short input names; emitted attributes use established semantic conventions when one exists.
| Input | Emitted attribute | Profile |
|---|---|---|
request_id |
cail.request.id |
both |
action_id |
cail.action.id |
both |
call_id |
cail.call.id |
both |
usage_id |
cail.usage.id |
platform |
http_method |
http.request.method |
both |
route |
url.template |
both |
status |
http.response.status_code |
both |
trace.trace_id |
log-record trace_id |
both |
trace.span_id |
log-record span_id |
both |
trace.trace_flags |
log-record trace_flags |
both |
terminal.outcome |
cail.outcome |
both |
terminal.reason |
cail.outcome.reason |
both |
error_type |
error.type |
both |
req_bytes |
http.request.body.size |
both |
resp_bytes |
http.response.body.size |
both |
principal.type |
cail.principal.type |
platform |
principal.subject |
enduser.pseudo.id |
platform |
cohort |
cail.cohort.id |
platform |
product_id |
cail.product.id |
platform |
project |
cail.kale.project.name |
platform |
provider |
gen_ai.provider.name |
platform |
request_model |
gen_ai.request.model |
platform |
response_model |
gen_ai.response.model |
platform |
input_tokens |
gen_ai.usage.input_tokens |
platform |
output_tokens |
gen_ai.usage.output_tokens |
platform |
cost_micro_usd |
cail.gen_ai.cost.micro_usd |
platform |
usage.kind |
cail.usage.kind |
platform |
usage.unit |
cail.usage.unit |
platform |
usage.quantity |
cail.usage.quantity |
platform |
HTTP methods use the OpenTelemetry known-method vocabulary plus _OTHER.
Routes must be templates such as /users/{user_id}, never raw request paths or
URLs, and are capped at 160 characters. Product outcome is explicit and does
not derive from HTTP status, so an application failure returned in an HTTP 200
response remains visible. Outcome and terminal reason must be coherent: for
example, ok pairs with completed, while timeout pairs with timeout.
error.type on an ok event is a contract error rather than a silently
corrected record.
principal, trace, and terminal are atomic input facts. Their nested,
discriminated types prevent partial or contradictory combinations before
runtime: identified users and canaries require a pseudonymous subject;
anonymous, app, and service principals cannot carry one; trace context is
all-or-nothing; and each outcome accepts only its closed reason set. The sink
still emits scalar OpenTelemetry-aligned record fields and attributes.
service.name is the emitting component. product_id is trusted per-event
attribution for a fleet product such as Workbench or Site Studio. project is
only a Kale Deploy tenant project. Shared gateways must not conflate these
three scopes.
The canonical subject shape is cail- plus 32 lowercase hexadecimal
characters. It is pseudonymous, not anonymous: a stable pseudonym can still be
linkable personal data. Prefer a coarse, policy-defined cohort when a
per-person view is not necessary.
Quota input contains kind, its matching unit, state, limit, used, and
an ISO-8601 reset timestamp. The logger derives remaining as
max(limit - used, 0) and emits scalar cail.quota.* attributes.
log.emit(CAIL_EVENTS.QUOTA_CHARGED, {
product_id: "kale-workbench",
principal: {
type: "user",
subject: "cail-0123456789abcdef0123456789abcdef",
},
terminal: { outcome: "ok", reason: "completed" },
quota: {
kind: "model_spend",
unit: "micro_usd",
state: "fresh",
limit: 10_000_000,
used: 188_977,
reset_at: "2026-08-12T16:00:00.000Z",
},
});Valid pairs are model_spend/micro_usd, request_count/requests,
build_count/builds, storage/bytes, compute/milliseconds, and
sandbox_compute/gib_seconds.
Quota is a window snapshot; settled usage is an immutable measured occurrence.
The canonical cail.sandbox.usage.settled event requires a platform-minted
usage_id, trusted product and principal attribution, and exact integer
sandbox_compute/mib_milliseconds. A quota snapshot may accompany it.
The log is not the charge authority. SandboxMeter settlement and durable accounting ingestion happen first. The event is emitted only after accounting acknowledges the idempotent usage fact.
The source settlement may mint usage_id before accounting delivery succeeds,
so the same ID can correlate bounded outbox retries. Those retries use a
service-local event such as sandbox_bridge.outbox.delivery_failed, defined
with extendCailEventCatalog(). They must not emit
cail.sandbox.usage.settled or otherwise claim accounting acknowledgement.
request_id identifies one HTTP request. action_id identifies a user-facing
workflow attempt that can span requests, retries, model calls, and sandbox
work. call_id identifies one billable child call. usage_id identifies one
immutable source settlement fact, such as sandbox compute, and may correlate
its idempotent accounting-delivery retries. The canonical settled log event
additionally means the accounting service acknowledged that fact.
All four use lowercase UUID v4 values. A trusted boundary must mint action,
call, and usage IDs;
tenant-supplied identifiers are diagnostic hints until a collector validates
their provenance.
correlationFromHeaders() accepts Headers, a Request-like { headers }, or
a structural { get(name) } reader. It adopts a valid W3C trace, creates a new
span for the current hop, and adopts or mints a lowercase UUID v4
X-CAIL-Request-Id.
const correlation = correlationFromHeaders(request.headers, {
sampled: span.isTraced,
});
log.emit(CAIL_EVENTS.ACTION_ADMITTED, {
action_id: "9f50d4a4-ef70-41b2-b225-0a5cbf2df5e7",
product_id: "kale-workbench",
principal: { type: "anonymous" },
request_id: correlation.request_id,
trace: correlation,
});If sampled is omitted, an inbound sampled decision is preserved; a new trace
defaults to 0, as required for a deferred decision. The helper never invents
a sampled decision. outboundCorrelationHeaders() validates the correlation
and writes the matching traceparent, request ID, and normalized tracestate.
W3C-valid empty tracestate list members are accepted and removed; an entirely
empty value is not forwarded.
The optional onDiagnostic callback receives one closed code: clock_error,
event_contract_error, event_invalid, event_dropped, or sink_error. It
never receives the original error or event content.
The logger contains synchronous throws and rejected promise-like returns from
both sinks and diagnostic callbacks. It does not await asynchronous delivery.
A Cloudflare sink that performs I/O must synchronously register that promise
with ExecutionContext.waitUntil() so the runtime keeps it alive; returning a
promise to cail-log only gives the library a rejection to contain.
This fire-and-forget behavior means cail-log is not an accounting ledger and
cannot prove that every admitted action reached a terminal state. The durable
action/call store is authoritative; log events are diagnostic projections of
admission and terminal transitions.
sensitive(value) wraps a secret so string conversion, JSON serialization,
template interpolation, and Node inspection produce [REDACTED]. A wrapper in
an allowed event field causes a content-free contract failure and drops the
event. Deliberately reading .value unwraps the secret for application use.
The core contract is pinned for this candidate to OpenTelemetry semantic
conventions 1.43.0. GenAI attributes are pinned to
open-telemetry/semantic-conventions-genai commit
63f8200eee093730ce845d26ce2aafb621b0807e; that project currently has no
published release or schema URL. An upgrade is an explicit schema review, not
an automatic rename.
The contract follows the OpenTelemetry Logs Data Model, OpenTelemetry semantic conventions, and the W3C Trace Context Recommendation. The Cloudflare projection follows Workers Logs structured JSON guidance. Privacy and failure behavior follow the OWASP Logging Cheat Sheet.
Semantic conventions evolve, especially GenAI attributes. A future convention change requires an explicit schema decision; the package will not silently rename emitted fields.
bun install
bun test
bun run typecheck
bun run buildThe suite covers the record envelope, Cloudflare projection, closed event catalogs, trust profiles, quota consistency, hostile inputs, failure containment, W3C propagation, and a PII-shaped canary attempted through every runtime field.
See DESIGN.md for the design gate, boundaries, pilot requirements, and rollback plan.
MIT — see LICENSE.