Logging
Frigg writes one redacted JSON record per line to stdout. Every record has the same field set and the correlation ids of ADR-011. The decision is ADR-048. This guide tells you how to write, read and test logs.
For integration and API module code, start with Logging in an Integration.
Quick start
In an integration or an API module, use this.logger. It already has its name and its ids:
async processContactBatch({ data }) {
this.logger.info('Contact batch started', {
eventName: 'integration.hubspot.batch_started',
batchSize: data.contacts.length,
});
}In core, get a named logger one time per module:
const { getLogger } = require('../logs');
const log = getLogger('frigg.core.sync');
log.warn('Sync skipped', { eventName: 'frigg.core.sync.skipped', processId });The message is first. Keep it fixed text, so a query can group it. Put ids and counts into fields.
A logger has
trace,debug,info,warn,error,fatal(message, fields?),child(bindings)andisLevelEnabled(level).child(bindings)adds fields to each record. Give a function to read the values per record:log.child(() => ({ processId: this.processId })).The logger never throws. It never uses
console.*.
The record
One record, shown on several lines (stdout has it on one line):
Field reference
timestamp
string
logger, RFC 3339 UTC with milliseconds
always
level
string
TRACE, DEBUG, INFO, WARN, ERROR, FATAL
always
message
string
call site; an Error becomes its sanitized message, other values become [non-string message] plus value
always
logger
string
getLogger(name): frigg.<area>, integration.<name>, module.<name>
always
appName
string
FRIGG_STACK
when set
stage
string
STAGE
when set
requestId
string
invocation scope, from context.awsRequestId
inside createHandler and the DLQ processor
handlerName
string
the eventName option of createHandler
inside createHandler
method, routeKey
string
the HTTP event
HTTP invocations
route
string
the route template, never the concrete path: REST v1 resource, or the path part of the HTTP API v2 routeKey (/api/integrations/{id}); the path only when the event has no template
HTTP invocations
invocation
object
the bounded event summary: { source, method, route, routeKey } for HTTP, { source, recordCount } for SQS, else { source }
inside an invocation scope
invocation.path, invocation.queryKeys, invocation.headerNames
string, string[], string[]
the full request summary: the concrete path after redactUrl (token-looking segments become [REDACTED:<len>]), the query keys and the header names, never values
only on frigg.handler.invoked (INFO), frigg.handler.failed and frigg.handler.halted (ERROR), and frigg.http.request_failed (ERROR)
messageId, receiveCount
string, number
message scope, from the SQS record
inside Worker.run and the DLQ processor
processId, integrationId, integrationEvent
string
message scope (the message body), or the integration context
when known
integrationType, userId, version
string
the ADR-011 integration context
when known
entityId, credentialId
string
Module bindings
in API module records
eventName
string
call site: the logger name, then .<action>
required for frigg.* at WARN and above, and for framework lifecycle INFO
trace_id, span_id, trace_flags
string (hex)
the active OTel span
inside a span only
error
object
{ type, message, code, status, stack, cause }, sanitized
when a call site passes error
droppedKeys
string[]
logger
when the logger removed a field (see Precedence)
Call-site fields go next to these, at the top level.
Rules the logger applies
Precedence. Required, resource and trace fields win, then scope fields, then
child()bindings, then call-site fields. A key that loses goes todroppedKeys. So a call site cannot replacerequestIdorlevel.Reserved keys. The logger never writes
tenantId,type,time,record,errorType,errorMessage,stackTrace,service,env,host,source,statusorseverityat the top level. They go todroppedKeys. UsestatusCodefor an HTTP status. Nested keys such aserror.statusandinvocation.sourceare fine.Payloads.
body,rawBody,payloadandresponseare dropped atINFOand above. Log counts or keys instead.Caps. A string is cut at 2,048 characters after redaction. Object depth is 6 (
[Depth]),causedepth is 3. A record is at most 16 KB: the logger drops call-site fields largest first, thenerror.stack, thenerror.cause. It always writes the record.Null.
nullandundefinedfields are left out.invocationdetail. A call-siteinvocationobject adds keys to the scope'sinvocation; a key the scope already has keeps the scope value (droppedKeysgetsinvocation.<key>when they differ).
Nested scopes merge
telemetry.withContext(ids, fn) now merges into the outer scope instead of replacing it. An inner undefined id keeps the outer value; an explicit null clears it (stored as null, left out of log records).
Redaction
Redaction runs inside the logger for every record. Call sites carry no redaction duty, but redaction is a backstop, not a permission: do not log a token and trust the logger.
Keys that end in
token,secret,password,apikey,privatekey,signature,authorizationorcookie(case,-and_ignored), plushashwordand the encryption registry fields, get[REDACTED].A
headersobject becomes its names. URLs lose userinfo, and each query value becomesREDACTED.Strings are scrubbed: Bearer and Basic credentials, JWTs, credentialed connection strings,
client_secret=/code=/signature=pairs, long base64 and hex runs become[REDACTED:<len>]. A key that ends insha256,hash,digestorchecksumkeeps its hex value.An
Errorat any depth goes through the error serializer.
The rules cover credentials, not personal data. Use DEBUG in production only for a limited time.
Stability rule
The record contract is public. Queries, subscription filters and sinks read it. A release can add a field. A rename or removal of a field needs a core major version. Records carry no schema version: the core version that the app deploys defines the schema.
Levels and FRIGG_LOG_LEVEL
TRACE
Step detail. Off by default.
Requester backoff
DEBUG
Diagnostics, redacted payloads.
OAuth callback steps
INFO
Lifecycle facts.
Handler entry; status changed
WARN
Handled. The system continues.
SQS retry; inbound 4xx; config fallback
ERROR
The operation failed.
Unhandled 5xx; halt; DLQ
FATAL
The process cannot continue.
Invalid config at INIT
Log an error one time, at the boundary that decides its outcome. Inner layers throw with cause and do not log. createHandler, the express error middleware, Worker.run and the DLQ processor are the boundaries.
FRIGG_LOG_LEVEL sets the minimum level. The logger drops a record below it before it reads any field.
Default
INFO. A local run (STAGE=local, orIS_OFFLINE=trueor1) defaults toDEBUG.Case and spaces do not matter. An empty value counts as unset. An unknown value means
INFO, and the logger writes onefrigg.logger.invalid_levelWARN.When
AWS_LAMBDA_LOG_LEVELis also set, the less verbose level wins, with onefrigg.logger.level_conflictWARN when they differ.DEBUG_VERBOSE=1meansDEBUGwhileFRIGG_LOG_LEVELis unset (legacy).The logger reads the level one time, at the first record, and never from SSM.
Build expensive detail only when it is written:
Reading logs
Locally with jq
fromjson? skips lines that are not JSON (CLI output, legacy console.* lines):
osls offline runs handlers in worker threads, so the JSON lines of several workers can reach the pipe out of order. Use --useInProcess when you pipe to jq.
CloudWatch Logs Insights
The fields need no parse step:
Subscription-filter patterns
JSON filter patterns match the record fields directly:
Destinations
stdout is the source of truth. On AWS, ship logs out of process first. That keeps the send, its credential and its failures out of the invocation:
Lambda log delivery to Amazon Data Firehose (then to S3 or a vendor).
A CloudWatch Logs subscription filter (patterns above) to a Lambda, Firehose or Kinesis.
A vendor Lambda extension.
An OpenTelemetry collector layer.
An in-process destination sink (ADR-048 §11) is not public yet. The internal sink interface exists (the stdout and memory sinks use it), and runInvocationScope already flushes sinks against the invocation deadline. These rules wait for the first real destination, and ship with it:
the destination contract
{ name, minLevel, translate(record), send(batch, { signal }) };appDefinition.logging.sinksregistration and its schema entry;one buffer per sink, at most 1,000 records or 1 MB, with drop counts;
one flush per sink at a time; abort and drop at the deadline, no retry in a later invocation;
the fixed stderr lines
frigg.logger.sink_failedandfrigg.logger.sink_disabled, never with the error message or the URL;disable a sink after 3 failed flushes in a row;
raise a
minLevelthat is less strict than the effective level, with one warning.
An in-process sink in a VPC needs an egress path.
Deployment: the logging block
No block, no change. Without
logging, devtools emits noLoggingConfig, no retention and noFRIGG_LOG_LEVEL, andframeworkVersionstays as it is.level(trace…fatal, all lower or all upper case):A level other than
infosets the literalFRIGG_LOG_LEVEL=<LEVEL>on every function. It wins overenvironment: { FRIGG_LOG_LEVEL: true }.Under
frigg start,logging.levelapplies too,infoincluded (a local run without a level defaults toDEBUG). AFRIGG_LOG_LEVELin the shell or in.envwins over it, for the local run only. Deploys never read the shell value.An unknown level fails the build.
Lambda stays on its default
Textlog format for now. The Lambda JSONLoggingConfig(provider.logs.lambda,frameworkVersion >=3.58.0) waits for the ADR-048 Phase 0 checks;FRIGG_LOG_LEVELfilters in process until then. When it is on, do not set a function-levellogsblock: it replaces the provider block, it does not merge with it.
retentionInDayssetsprovider.logRetentionInDaysfor every Lambda log group. Allowed values are the CloudWatch list (1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653). Any other value fails the build before CloudFormation starts. The deployment IAM policy grantslogs:DeleteRetentionPolicy, so you can remove the setting later.formataccepts onlyjson.SSM offload:
FRIGG_LOG_LEVEL,AWS_LAMBDA_LOG_LEVEL,AWS_LAMBDA_LOG_FORMAT,OTEL_FLUSH_TIMEOUT_MS,DEBUG_VERBOSEandPRISMA_LOG_LEVELare read at INIT, so they never move to SSM.frigg initwriteslogging: { level: 'info', retentionInDays: 30 }for new apps.
Testing
jest-logger-setup.js (registered in packages/core/jest.config.js) installs a memory sink before each test, sets the level to TRACE, and fails a test that wrote a frigg.* WARN or above with no eventName. No JSON reaches the test output.
Assert on records, not on console spies:
createMemorySink()installs itself as the only sink.createMemorySink({ install: false })gives a sink to pass toresetLoggerForTests({ level, sinks }).resetLoggerForTests()re-reads the env on the next record. Passsinks, or it installs the stdout sink.Records in the sink are deep-frozen, and
JSON.stringify(record)equals the stdout line.To prove that no secret leaks, core tests use
expect(sink.records).toContainNoSecretWindow(secrets)(registered by the setup file; the fixtures are inpackages/core/logs/__fixtures__/). Use fake secrets of 16 characters or more.
A test outside packages/core can add the same setup file: setupFilesAfterEnv: ['@friggframework/core/logs/jest-logger-setup.js'].
Migrating from debug, initDebugLog, flushDebugLog
The three functions are deprecated shims for one major version. They write records on the frigg.legacy logger and keep no module state:
debug(...args)
one DEBUG record; the first string is the message (util.format when it has a % directive), the rest goes to args
this.logger.debug(message, fields)
initDebugLog(eventName, event)
nothing inside an invocation scope; else one DEBUG record with the redacted invocation
nothing: createHandler opens the scope
flushDebugLog(error)
one ERROR record, eventName: 'frigg.legacy.error', no replay of debug lines
log one time at the boundary, or throw with cause
Records below the effective level are not kept for a later flush. Set FRIGG_LOG_LEVEL=debug to see debug records.
Migrating a call site
Adoption is incremental: new code uses the logger, and existing console.* calls move when someone touches them. Until then a log group has JSON and text lines, and the text lines have no correlation ids and skip redaction.
Get the logger:
this.loggerin an integration or an API module,getLogger('frigg.<area>')in core (one per module, at module scope).Pick the level from the table above, not from the old console method.
Make the message fixed text. Move ids, counts and names into fields.
Give each
frigg.*record atWARNand above, and each lifecycleINFO, aneventName: the logger name, then.<action>.Pass an error as
{ error }. Do not log and rethrow; the boundary logs.Do not log bodies, headers or tokens at
INFO. Log keys or lengths (dataKeys,bodyLength).In the test, replace the console spy with a memory sink and assert by
eventName. Keepexpect(consoleSpy).not.toHaveBeenCalled()so the old call cannot come back.
Last updated
Was this helpful?
