Logging & Observability
TriFrost was built with observability in its DNA β not as an add-on, but as a first-class, runtime-aware, autoredacting logging system designed for real-world production use.
This article dives into:
- The logging & tracing architecture
- Exporter system (Console, JSON, OTEL)
- Spans & trace propagation
- Attribute handling & scrubbing
- Best practices for production observability
Looking for how to use
ctx.logger
? See the Logger API article for methods, examples, and usage patterns.
π§± Architecture
Every TriFrost request gets a scoped, context-aware logger with:
- Unique
traceId
(32-char OTEL-compliant hex) - Active
spanId
(16-char OTEL-compliant hex), if within a span - Attached
ctx
and app metadata - Per-request attribute store
This is backed by a Root Logger, which handles app-level metadata and exporter setup. Loggers are lazily spawned per-request from the Root Logger and flushed automatically at the end of each request.
You will never need to touch the root logger directly as this is internal to TriFrost, but just imagine something akin to the following pseudo-code
/* App-level root setup */
const rootLogger = new TriFrostRootLogger({
name: cfg.name,
version: cfg.version,
debug: true,
rootExporter: new ConsoleExporter(),
exporters: env => [... (your configured exporters)]
});
/* On incoming request */
async onIncoming (ctx) {
ctx.logger = rootLogger.spawn({
... ctx-specific bits
});
}
π Spans & Tracing
Spans form a core part of the open telemetry specification, they represent a single unit of work in your system β think of it like a timer wrapped around a specific operation.
For example:
- A span might cover "fetching user from database."
- Another span might wrap "rendering HTML response."
Together, spans form a tree of where time was spent inside a request.
Spans can also carry metadata (attributes) and are linked together by a shared trace ID, letting observability tools reconstruct the full picture of a request as it moves through your system.
In short: spans tell you what happened, when, and how long it took β across services, layers, and functions.
TriFrost has first-class span support β both inline and decorator-based. TriFrost also spans middleware automatically, so you get performance visibility across the full request lifecycle.
There's built-in helpers you can use for this:
// Inline block span
await ctx.logger.span("fetchUser", async () => {
// your logic
});
// Manual start/end
const span = ctx.logger.startSpan("transformData");
span.setAttribute("foo", "bar");
...
span.end();
For services or helpers outside of ctx-driven handlers, TriFrost provides trace decorators:
import {span, spanFn} from '@trifrost/core';
class UserService {
@span("user.load")
load(ctx) { ... }
}
const process = spanFn("job.process", async ctx => { ... });
Both look for:
ctx.logger
- or
this.logger
/this.ctx.logger
If no logger is found, they gracefully no-op β your function runs unwrapped.
Excited about spans and tracing? See the Logger API article for methods, examples, and usage patterns.
Pitfalls
- If you call
setAttribute(...)
after a span is started, it wonβt retroactively affect earlier spans. - Always
end()
spans β or usectx.logger.span(name, fn)
, the@span
decorator orspanFn
function which handle this for you.
π¦ Exporters
TriFrost uses an exporter model β logs and spans are pushed to one or more exporters, which handle formatting, redaction, and delivery to their final destination (console, file, or observability backend).
This architecture lets you:
- Mix exporters (e.g. console + OTEL)
- Customize per-environment output
- Control log shape, sink, and security behavior
You define exporters in your app setup:
import {App, ConsoleExporter, OtelHttpExporter} from '@trifrost/core';
new App({
tracing: {
exporters: ({env}) => [
new ConsoleExporter(),
new OtelHttpExporter({
logEndpoint: env.OTEL_LOGS,
spanEndpoint: env.OTEL_SPANS,
})
]
}
});
Available Exporters
- ConsoleExporter
Structured logs toconsole
with grouping and formatting, ideal for local dev and CI runs. - JsonExporter
Emits NDJSON-formatted logs toconsole
or custom sink, ideal for File-based logs, piping. - OtelHttpExporter
Sends logs + spans to an OTLP-compatible backend, ideal for production observability
You can use multiple exporters at once, and they will each receive the full log/span stream independently.
π‘ Want dynamic behavior between dev and prod? Use TriFrostβs isDevMode() utility for easier handling.
π For detailed examples, configuration options, and best practices, check each exporter's dedicated guide: ConsoleExporter Guide, JsonExporter Guide, OtelHttpExporter Guide
Redaction + Safety
All exporters support the omit
option to scrub sensitive fields. See Scrambling Hygiene for details on configuration and built-in presets.
π‘ Redaction (Scrambling) Support
Out of the box, TriFrost will scrub sensitive keys option for redacting sensitive or personally identifiable information from logs.
This is powered internally by a high-performance scrambler engine capable of deeply scanning objects and masking fields using:
- Path-based matching β e.g.
'user.token'
- Global key matching β e.g.
{global: 'token'}
(matches at any depth) - Regex-based value matching β e.g.
{valuePattern: /\d{3}-\d{2}-\d{4}/}
(matches SSNs, emails, etc.)
Scrubbed values are replaced with ***
, making redaction visible without losing context.
A default set of safe redaction rules is available via OMIT_PRESETS.default
(but this is the default so unless customizing you dont need to use this):
import {OMIT_PRESETS} from '@trifrost/core';
new JsonExporter({
omit: [...OMIT_PRESETS.default, {global: 'custom_secret'}],
});
For example:
{
user: {
id: 42,
full_name: 'Jane Doe',
email: 'jane.doe@example.com',
preferences: {
theme: 'dark',
newsletter: true,
},
},
auth: {
method: 'oauth',
token: 'abc123',
},
activity: {
message: 'User with email jane.doe@example.com logged in from +1 (800) 123-4567',
timestamp: '2025-06-09T12:00:00Z',
},
}
Becomes:
{
user: {
id: 42,
full_name: '***',
email: '***',
preferences: {
theme: 'dark',
newsletter: true,
},
},
auth: {
method: 'oauth',
token: '***',
},
activity: {
message: 'User with email *** logged in from ***',
timestamp: '2025-06-09T12:00:00Z',
},
}
TriFrost maintains a sensible list of defaults which lives in the OMIT_PRESETS
constant within the TriFrost codebase (available through import {OMIT_PRESETS} from '@trifrost/core'
).
OMIT_PRESETS.default = [
/* Sensitive */
{global: 'access_token'},
{global: 'api_key'},
{global: 'api_secret'},
{global: 'apikey'},
{global: 'apitoken'},
{global: 'auth'},
{global: 'authorization'},
{global: '$auth'},
{global: 'client_secret'},
{global: 'client_token'},
{global: 'id_token'},
{global: 'password'},
{global: 'private_key'},
{global: 'public_key'},
{global: 'refresh_token'},
{global: 'secret'},
{global: 'session'},
{global: 'session_id'},
{global: 'sid'},
{global: 'token'},
{global: 'user_token'},
{valuePattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/},
/* PII */
{global: 'first_name'},
{global: 'last_name'},
{global: 'full_name'},
{valuePattern: /[\w.-]+@[\w.-]+\.\w{2,}/}, /* Email */
{valuePattern: /\+?\d{1,2}[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}/}, /* Phone */
{valuePattern: /\b\d{3}-\d{2}-\d{4}\b/}, /* SSN */
{valuePattern: /\b(?:\d[ -]*?){13,16}\b/}, /* Credit card */
];
Expand on defaults
Each exporter supports configuring the omit
behavior, for example lets say you want to redact every key with the name ssn
:
import {OMIT_PRESETS} from '@trifrost/core';
new ConsoleExporter({omit: [...OMIT_PRESETS, {global: 'ssn'})
I dont like the defaults
We're saddened to hear that, but in case you really don't like the defaults you simply ... omit ... the OMIT_PRESETS:
new ConsoleExporter({omit: [{global: 'ssn'});
In the above example only the ssn key will be scrubbed, up to you to ensure no other sensitive data reaches your logs π«‘, good luck soldier!
I dont want redaction
Without asking why? (I guess you have your reasons):
new JsonExporter({omit: []}); /* Or Console/Otel exporter */
π§© Attributes
In TriFrost, structured logging isnβt just about message
and level
. It's about context β the who, what, where of every log line and span.
Attributes are the key.
This section covers:
- What attributes are and why they matter
- How attributes propagate across logs and spans
- Best practices for setting, managing, and securing them
What Are Attributes?
Attributes are structured key-value pairs that attach to logs and spans.
They can represent:
- Internal IDs (
userId
,tenantId
) - Environment markers (
region
,runtime
) - Flow markers (
step
,phase
) - Any other contextual metadata you want to enrich your observability with
Theyβre not visible in the top-level log message β but they power your observability backendβs ability to filter, group, correlate, and debug.
Built-in Attributes
TriFrost automatically attaches a small set of global attributes to every log and span:
Globally (App-wide):
service.name
: Your app name (from config)service.version
: Your app version (from config)runtime.name
: Active runtime (node, bun, workerd, ...)runtime.version
: Detected runtime version (if applicable, eg:20.12.0
)telemetry.sdk.name
:'trifrost'
telemetry.sdk.language
:'javascript'
Per Request:
http.method
: request method (eg:GET
)http.target
: request path (eg:/user/4794
)http.route
: registered route (eg:/user/:userId
)http.status_code
: eventual status codeotel.status_code
: otel status codeuser_agent.original
: User Agent header
Note: These are named like that because of how the OTEL structured spec works and they will be picked up by observability platforms correctly that way. For example look at the sidebar in the following image:


Setting Attributes
All TriFrost loggers (including ctx.logger
) support attribute injection:
ctx.logger.setAttribute("userId", ctx.state.user.id);
ctx.logger.setAttributes({
tenantId: ctx.state.tenant.id,
plan: ctx.state.tenant.plan
});
Once set, these attributes apply to:
- All subsequent logs
- Any new spans created after the set
Span Inheritance
When you start a span (ctx.logger.span(...)
, @span
, spanFn()
), it captures a snapshot of the current attributes.
ctx.logger.setAttribute("db.shard", "eu-1");
await ctx.logger.span("loadUser", async () => {
// span includes { db.shard: 'eu-1' }
});
Set attributes before creating spans to ensure correct propagation.
If you need to attach attributes after a span has started, use:
const span = ctx.logger.startSpan("custom");
// ... your logic
span.setAttribute("foo", "bar");
// ... your logic
span.end();
Avoid Overlogging
Not everything should be an attribute.
Do not treat attributes as a dumping ground for:
- Raw PII (email, address, etc.)
- Sensitive tokens
- Full error objects or stack traces
Use attributes for identifiers and stable markers β keep the noisy or risky bits in the data
field (second argument for logs).
Redacting Attributes
If you ever accidentally attach sensitive data, donβt worry β all attributes are passed through the omit scrambler system, just like data
and ctx
.
This means:
ctx.logger.setAttribute("access_token", "supersecret");
will become:
"access_token": "***"
π See Scrambling Hygiene for full explanation
Best Practices
- β
Use static message strings + structured
data
. These help with observability and allow for grouping and easier introspection - β
Enrich attributes with your own internal identifiers such as
userId
,tenant
, etc. viasetAttribute(...)
orsetAttributes({...})
- β Enrich attributes using stable identifiers (eg: 'userId'), not transient or verbose values.
- β
Enrich attributes using flat and readable keys (e.g.
userId
, notmeta.user.details.uid
). - β Span expensive or critical operations such as database calls, this adds richness and allows you to pinpoint performance problems.
- β Let TriFrost handle request lifecycle β donβt double-wrap middleware
- β Never log secrets, tokens, or raw PII β trust the scrambler, but donβt rely on it blindly, be vigilant π
- β Treat attributes as your semantic contract with observability