Logs, audit and telemetry
logStructured logs with redaction built in, a tamper-evident audit log, and errors with a stable code and a fix. Export to OpenTelemetry is planned.
log-telemetryFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
log gives every app structured logs with redaction built in and a tamper-evident audit log. Errors thrown with MainError carry a stable code and a fix.
At a glance
logging: { audit: true, redact: [personal, sensitive, child, phi] }
telemetry:
to: [terminal, otel]
otel: { endpoint: https://otel.pinecart.dev:4318, headers: { authorization: secret:OTEL_TOKEN } }
sample: 0.2
logging:
errors:
driver: otlp # otlp | sentry | local
release: auto # the build hash, else MAINSDK_RELEASE, else the package version
process: true # capture uncaught errors, flush, exit 1
browser: false # serve POST /_mainsdk/errors for reportErrors()
ignore: [AbortError]
alert: { new: warning, spike: { over: 50, per: 5m, severity: critical } }import { log, MainError } from "mainsdk";
log.info("order shipped", { orderId: order.id });
throw new MainError("MAIN-DATA-0007", { fix: "Query with db.orders.for(user)" });app/log.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { MainError } from "mainsdk";
describe("logging", () => {
it("redacts personal fields and chains audit records", () => {
const app = createTestApp({ main: "./main.yaml" });
app.log.info("signup", { email: "a@b.co" });
assert.equal(app.log.records.at(-1)?.attributes.email, "[redacted]");
app.log.audit({ actor: { kind: "user", id: "u1" }, action: "plan.change", decision: "allow" });
assert.equal(app.log.verifyAudit().ok, true);
});
it("turns a MainError into problem details", () => {
const body = new MainError("MAIN-DATA-0007", "Not yours", { status: 403, fix: "Read through db.orders.for(user)" }).toProblem();
assert.equal(body.code, "MAIN-DATA-0007");
});
});Calls
log.info()log.error()log.audit()log.audit.verify()new MainError(code, { fix })problem(err)log.debug(msg, attrs?)log.warn(msg, attrs?)log.verifyAudit({ from? })verifyChain(records)currentTrace()newTraceId()withTrace(id, fn)fileSink(path)log.recordslog.auditRecordsreadAuditFile(path)instance(object: id)log.addRedactor(fn)log.addSecret(value)log.auditFor(tenant)log.reload(opts)log.holdsSecret(text)log.redactData(attrs)log.redactString(text)log.secretValues()log.errors.list(query?: { since?, release?, cursor?, limit? })log.errors.get(fingerprint)auditEvent(input)parseErrorReportConfig(logging)errorConfigIssues(logging, ctx)reportErrors(opts?: { endpoint?, sample? })
Drivers
4 drivers. Select one to see its line in main.yaml.
telemetry:
to: terminalChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-REPORT-0005 | Checkmainsdk check, before deploy | logging.errors is invalid: sentry with no dsn, a dsn host not in network.allow, a sample outside 0 to 1, or alert.spike.over under 1 |
MAIN-LOG-0020 | Checkmainsdk check, before deploy | A password, key, token or authorization value is passed to a log call |
MAIN-LOG-0003 | RuntimeError responses and logs | MAINSDK_DEBUG=driver ignored in production |
MAIN-LOG-0005 | RuntimeError responses and logs | An attribute not in telemetry@1 was dropped before export |
MAIN-LOG-0010 | RuntimeError responses and logs | Audit sink unavailable; buffering to local disk |
MAIN-REPORT-0001 | RuntimeError responses and logs | See the spec |
MAIN-REPORT-0002 | RuntimeError responses and logs | See the spec |
MAIN-REPORT-0003 | RuntimeError responses and logs | See the spec |
MAIN-REPORT-0004 | RuntimeError responses and logs | See the spec |
MAIN-REPORT-0006 | RuntimeError responses and logs | See the spec |
MAIN-REPORT-0007 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Logs on the OpenTelemetry data model | Phase 1 | 0.2 | log-telemetry#log-api |
| Local event store for 7 days | Phase 1 | 0.2 | log-telemetry#local-store |
| In-process redaction | Phase 1 | 0.2 | log-telemetry#redactor |
| Hash-chained audit log in OCSF | Phase 1 | 0.2 | log-telemetry#audit-log |
| telemetry@1 field contract | Phase 1 | 0.2 | log-telemetry#telemetry-contract |
| OTLP export | Phase 1 | 0.2 | log-telemetry#otlp-export |
| Opt-in sending to MainSDK Cloud | Phase 1 | 0.2 | log-telemetry#opt-in |
| MainError with problem details | Phase 1 | 0.2 | errors#main-error |
| Unhandled errors and crashes captured with a stable fingerprint | Phase 1 | 0.4 | error-reporting#fingerprint |
| Alerts on new and spiking error groups | Phase 1 | 0.4 | error-reporting#groups-alerts |
| Sentry driver with no SDK | Phase 1 | 0.4 | error-reporting#sentry |
| Opt-in browser error reports | Phase 1 | 0.4 | error-reporting#browser |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Teams | Pooled events with 30 days of history, a shared trace explorer, debug sessions and replay of failed requests |
| Fleet | Observability across every app and OCSF streaming to the SIEM |
Quick start
Logging works with no configuration: records print to the terminal, one line each.
app/api/orders/[id]/ship/route.ts
import { auth, log } from "mainsdk";
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth.require(req);
const { id } = await params;
log.info("order shipped", { orderId: id, by: session.user });
return new Response(null, { status: 204 });
}use mainsdk::prelude::*;
let app = App::test();
app.log().info("signed in").field("plan", "pro").await;
assert_eq!(app.log().records().len(), 1);Each record has an id, an RFC 3339 UTC time, a severity (DEBUG, INFO, WARN, ERROR), a body and attributes. Inside a request that carries a trace, attributes.trace_id holds it, and in a tenant, attributes["tenant.id"] does.
Configuration
logging:
audit: true # write the hash-chained audit log (default true)
redact: [personal, sensitive, child, phi] # labels redacted before any write| Key | Type | What it does |
|---|---|---|
logging.audit | boolean | Writes the append-only audit log; false turns it off |
logging.redact | list of labels | Field labels redacted in process. Default personal, sensitive, child, phi |
A push to the config store can only tighten these: add a label or turn audit on, never the reverse.
How long a person's log records are kept under data rights is set in privacy.retention.logs.
What gets redacted
Before a record reaches any sink:
- Attributes labelled
personalor another listed label (email,phoneandnameby default) are replaced with[redacted]. authorization,cookie,set-cookie,passwordandpanare always redacted.- Anything resolved from a
secret:reference, and known key formats such assk_live_…,AKIA…and GitHub tokens, are masked in messages and attributes. - An
ipattribute is trimmed to its /24 (IPv4) or /48 (IPv6).
What you get
Logs and the audit log are free and local from the first run. Redaction happens in process before any record is written. problem() turns a MainError into an RFC 9457 response. Reach for log everywhere you would use console.log, and wherever a decision must be provable later.
Examples
Throw an error with a fix
MainError takes a code, a message and options. problem() serialises it as application/problem+json; any other thrown value becomes a 500 that hides its message.
import { auth, MainError, models, problem, s } from "mainsdk";
const { orders } = models({
orders: s.object({ item: s.string(), total: s.int() }),
});
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await auth.require(req);
const { id } = await params;
const order = await orders.for(session.user).get(id);
if (!order) {
throw new MainError("MAIN-DATA-0007", "This record belongs to another owner", {
status: 403,
fix: "Query with db.orders.for(user)",
});
}
return Response.json(order);
} catch (err) {
return problem(err);
}
}{
"type": "https://mainsdk.dev/errors/MAIN-DATA-0007",
"title": "This record belongs to another owner",
"status": 403,
"code": "MAIN-DATA-0007",
"fix": "Query with db.orders.for(user)"
}retryable: true appears when the error is retryable, and retryAfter sets a Retry-After header. log.error(err) writes the code and fix with the record.
Write and verify an audit record
Policy decisions are audited for you. Write your own records for business events that must be provable. An event names the actor (kind is user, agent, system or service), the action, an optional resource string and a decision (allow, deny, pending or approved).
import { auth, log } from "mainsdk";
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth.require(req, { role: "admin" });
const { id } = await params;
log.audit({
actor: { kind: "user", id: session.user },
action: "refund_limit.grant",
resource: `user:${id}`,
decision: "allow",
});
return new Response(null, { status: 204 });
}import { log } from "mainsdk";
const result = log.verifyAudit({ from: "2026-10-01T00:00:00Z" });
if (!result.ok) {
console.error(`Audit chain broken at ${result.brokenAt}`);
process.exit(1);
}Each record carries the hash of the previous one, and each tenant has its own chain, so an edit, deletion or reorder makes verifyAudit return ok: false with the first broken record's id. To check records you read back from a file, use verifyChain(records).
Keep records in a file and read them with the CLI
Passing sinks replaces the terminal output. fileSink appends JSON lines, and mainsdk logs reads .mainsdk/events/*.jsonl.
import { configure, fileSink } from "mainsdk";
configure({ sinks: [fileSink(".mainsdk/events/app.jsonl")] });npx mainsdk logs --level error
npx mainsdk logs --trace 4bf92f3577b34da6a3ce929d0e0e4736 --json
npx mainsdk logs --followWithout --json, each line is the time, the severity and the message.
Error reporting
Every unhandled error is captured with no wiring: a request whose handler throws or answers 5xx, a job that lands in the dead-letter queue, a workflow run that fails, log.error(err) with an Error, and (by default) an uncaught exception or unhandled rejection. A MainError with a status under 500 is the caller's error and is skipped. Each capture is redacted, given a fingerprint, counted into a group and queued for export, and none of it waits on the network.
The fingerprint is the first 16 hex characters of SHA-256 over the error's code or name and its first three frames in your code (path relative to the project, function name, no line number). It stays the same across restarts, machines and line moves. With no frame in your code it uses the message with digits, long hex and UUIDs replaced by <n>.
logging:
errors:
driver: otlp # otlp (through telemetry.otel) | sentry | local
release: auto # the build hash, else MAINSDK_RELEASE, else the package version
process: true # capture uncaught errors, flush for at most 2 s, exit 1
browser: false # serve POST /_mainsdk/errors for reportErrors()
ignore: [AbortError]
alert:
new: warning # a group new in this release; off to disable
spike: { over: 50, per: 5m, severity: critical }import { log } from "mainsdk";
const page = await log.errors.list({ since: "1h", limit: 20 }); // newest first, with a next cursor
const group = await log.errors.get(page.items[0]?.fingerprint ?? ""); // 404 when unknown
console.log(group.count, group.frame?.file, group.isNew);npx mainsdk errors --since 1h --json # the groups under mainsdk dev, newest firstA browser reporter is one opt-in call, with logging.errors.browser: true on the server:
import { reportErrors } from "mainsdk/log/client";
const stop = reportErrors({ sample: 0.5 }); // listeners for error and unhandledrejection; stop() removes themIt sends { name, message, stack, path, release } with the query and fragment removed, one report per distinct error per page load and at most 10 a minute. The server answers 404 when browser is off, 413 over 16 KB and 429 over 10 a minute per session or address (MAIN-REPORT-0004).
Newness is decided across instances through kv when the app declares a kv section; without it, each instance decides for itself. A new group logs MAIN-REPORT-0001 and raises error:<fingerprint> once; a spike logs MAIN-REPORT-0002 and raises error-spike:<fingerprint>, resolved when the window empties. The sentry driver writes Sentry's envelope itself, through the network allowlist, with no Sentry SDK; it is written from Sentry's documented shapes and is not yet verified against a live Sentry server.
Reference
Calls beyond the examples above.
| Call | What it does |
|---|---|
log.debug(msg, attrs?) | Writes a DEBUG record. It is dropped unless the level is DEBUG. |
log.warn(msg, attrs?) | Writes a WARN record. |
log.records | The records this process kept in memory, oldest first. Tests read it to assert on what the app logged. |
log.auditRecords | Every audit record this process kept, across tenants, in the order written. log.auditFor(tenant) narrows it to one chain. |
log.audit.verify() | The specified name for chain verification. In 0.x the working call is log.verifyAudit({ from? }), which returns { ok, brokenAt? } and checks each tenant's chain. |
log.auditFor(tenant) | Returns one tenant's audit records in order, or the system chain. They verify on their own with verifyChain. |
log.reload(opts) | Changes audit and the redacted labels while the app runs, with { audit, redact? }. |
log.redactData(attrs) | Redacts attributes the way a record would be, for data that leaves in another envelope. |
log.redactString(text) | Redacts a string: secret values, known key formats and registered redactors. |
log.holdsSecret(text) | Returns true when the text holds a registered secret value or a known key format. |
log.addSecret(value) | Registers a resolved secret value so it never appears in any record. |
log.secretValues() | Returns a copy of the registered secret values for scans that must refuse them. Never log it. |
log.addRedactor(fn) | Registers a function that rewrites every string in every record, such as one that masks an API key format. |
readAuditFile(path) | Reads audit records back from a JSON-lines file written by fileSink; a missing file gives an empty list. |
newTraceId() | Returns a new trace id: 32 lowercase hex characters. |
currentTrace() | Returns the trace id of the work running now, or undefined outside any. |
withTrace(id, fn) | Runs fn under a trace id, so the jobs and records it makes carry the same trace_id. |
instance(object: id) | instance.id identifies this running copy of the app. It is random on each start. |
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-LOG-0003 | MAINSDK_DEBUG=driver set in prod (planned) | Unset it, or start a debug session |
MAIN-LOG-0005 | The exporter tried to send an attribute not listed in telemetry@1 (planned) | Remove the attribute, or keep it in local logs only |
MAIN-LOG-0010 | An audit sink threw; the record stays in memory and an ERROR record carries this code | Restore the sink; the audit chain is intact |
MAIN-REPORT-0001 | An error group is new in this release | Read the group with log.errors.get, then fix it or ignore it |
MAIN-REPORT-0002 | An error group passed alert.spike | Find the deploy or the dependency behind it |
MAIN-REPORT-0003 | The error driver could not deliver; events are dropped (logged once per outage) | Check the endpoint or DSN and network.allow |
MAIN-REPORT-0004 | A browser report was refused: off (404), over 16 KB (413) or over 10 a minute (429) | Set logging.errors.browser: true, or send fewer and smaller reports |
MAIN-REPORT-0005 | logging.errors is invalid (check) | Give sentry a dsn whose host is in network.allow, a sample from 0 to 1 and alert.spike.over of 1 or more |
MAIN-REPORT-0006 | The process hit an uncaught error; it was captured as fatal and exits with code 1 | Read the fatal group and fix the cause |
MAIN-REPORT-0007 | log.errors.get found no group with that fingerprint (404) | List the groups first |
Every code is listed in error codes and check codes.
Swapping drivers
The terminal sink is the only built-in destination, plus fileSink and any function of your own with the Sink shape. A planned otel driver will export to Datadog, Grafana, Sentry or any OpenTelemetry backend, and a planned mainsdk driver to MainSDK Cloud; your log.* calls and audit records stay the same.
Testing
The test app keeps every record in memory, so assert on app.log.records and app.log.auditRecords.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { MainError } from "mainsdk";
describe("logging", () => {
it("redacts personal fields and chains audit records", () => {
const app = createTestApp({ main: "./main.yaml" });
app.log.info("signup", { email: "a@b.co" });
assert.equal(app.log.records.at(-1)?.attributes.email, "[redacted]");
app.log.audit({ actor: { kind: "user", id: "u1" }, action: "plan.change", decision: "allow" });
assert.equal(app.log.verifyAudit().ok, true);
});
it("turns a MainError into problem details", () => {
const body = new MainError("MAIN-DATA-0007", "Not yours", { status: 403, fix: "Read through db.orders.for(user)" }).toProblem();
assert.equal(body.code, "MAIN-DATA-0007");
});
});See the Testing kit.
Security and privacy
- Redaction runs in process before any write: labelled fields, secrets and known key formats.
- IP addresses are trimmed;
authorization,cookieandset-cookievalues never appear. - Nothing leaves your app: no destination is built that sends logs elsewhere. When export arrives it will be opt-in, with a field contract that drops anything not listed.
FAQ
Is the audit log ever sampled? No. Every audit event is chained and kept.
Where does the audit log live in production? In memory, plus any sink you pass, such as fileSink. A database-backed or OCSF-stream sink is not decided yet.
What if an audit sink fails? The record stays in memory, an ERROR record with MAIN-LOG-0010 is written, and the call does not throw.
Next
- Error codes: every
MAIN-code with its cause and fix. - Policy decisions: decisions written to the audit log.
- Debug your app: from an error to a fix.
- Fail static: why logging never blocks a request.
Helpers for wiring and tests
| Call | What it does |
|---|---|
auditEvent(input) | Build one audit event from an input, as log.audit does before it writes. |
parseErrorReportConfig(logging) | Read the error reporting settings under logging in main.yaml. |
errorConfigIssues(logging, ctx) | List what is wrong in the error reporting settings, without throwing. |
Was this page helpful?