Logging
Available as context.logger. Four levels — info, warn, error, debug —
each takes a message string plus optional structured fields.
context.logger.info("Fetched 42 invoices from ERP")
context.logger.warn("Skipping malformed invoice INV-007")
context.logger.error("ERP returned 500", detail={'request_id': 'r-123'})
context.logger.debug("Raw payload", detail=payload)
Where log lines go
| In production | In the toolkit |
|---|---|
Stderr is captured by the worker, stored against the job's log_token, and surfaced in the admin portal log viewer. | Stderr prints to your terminal. That's it. |
Each call may also POST a structured log entry to /v1/job/log for the
structured-log viewer — this is enabled by the framework on a per-run basis
(Logger.attach_api()), not by anything in your settings. Off by default
— stderr is the durable record either way.
Format
Each call produces one stderr line:
2026-05-16 14:32:11 UTC [INFO] Fetched 42 invoices from ERP
Format is fixed — don't try to override it. If you need machine-readable
output, return a structured dict from run(). That's the connector's
contract for emitting "the result".
The detail and step keyword args
Both are optional. They're only consumed by the structured-log forwarder; the plain stderr line just gets the message text.
context.logger.info("Posting batch", step='upload', detail={'count': 50})
context.logger.error("SFTP login failed", step='sftp_connect',
detail={'host': sftp_host, 'attempt': 3})
Use step to tag where in the entrypoint a line came from — 'sftp_connect',
'fetch_external', 'save_to_nuntiq'. The structured log viewer groups by
step when it's set, which is much friendlier than reading the raw text in
the post-mortem.
Use detail for anything you'd want to filter or pivot on — request ids,
record counts, error codes, retry attempts.
What NOT to do
Don't print()
The framework redirects stdout to keep it clean for the result envelope.
print() works in the toolkit (where you can see it in the terminal) but
breaks result-parsing in production — your job will be marked failed
because the worker can't decode the result line.
print("hello") # ❌ corrupts result in production
context.logger.info("hello") # ✅ goes to stderr / log
Don't log secrets
detail is not redacted. Treat the structured log viewer like any other
observability surface — anyone with access to the log can see what you logged.
context.logger.info("Posting", detail={'token': api_token}) # ❌ secret leak
context.logger.info("Posting", detail={'auth': 'bearer ***'}) # ✅
Don't log INFO once per record
A 50k-row sync that logs every record produces 50k log lines. Log storage is bounded; downstream alerting can't cope; the post-mortem is unreadable.
# ❌ — one line per record
for r in rows:
context.logger.info(f"Saving {r['id']}")
save(r)
# ✅ — count at start and end; log on failure
context.logger.info(f"Saving {len(rows)} rows", step='save')
for r in rows:
try:
save(r)
except Exception as e:
context.logger.warn(f"Failed {r['id']}: {e}", step='save')
context.logger.info(f"Save complete: {n_ok} ok, {n_fail} failed", step='save')
Common patterns
Start/end markers
def run(context):
context.logger.info(f"Starting {context.entrypoint_key}", step='start')
...
context.logger.info(f"Done — processed {count} records", step='end',
detail={'count': count, 'failed': failed})
One line per external request that took unusual time
import time
t0 = time.monotonic()
resp = requests.get(url, timeout=30)
dt = time.monotonic() - t0
if dt > 5:
context.logger.warn(f"Slow external call ({dt:.1f}s)", detail={'url': url})
Wrap exceptions with context
try:
sftp.put(local_path, remote_path)
except paramiko.SSHException as e:
context.logger.error(f"SFTP put failed: {e}",
step='sftp_put',
detail={'remote': remote_path, 'local_size': os.path.getsize(local_path)})
raise # re-raise so the framework marks the job failed
What's next
- Work dir — what to write where, and what survives.
- Architecture — what happens to your stderr after the container exits.