ctx.log is the structured logger. Each call emits one OpenTelemetry log record correlated with the current execution’s trace, so log lines appear inline with HTTP, database, and KV spans on the execution timeline. Use it instead of console.log, which is captured only best-effort.
ContextLogger
interface ContextLogger {
info(message: string, metadata?: Record<string, unknown>): void;
warn(message: string, metadata?: Record<string, unknown>): void;
error(message: string, metadata?: Record<string, unknown>): void;
debug(message: string, metadata?: Record<string, unknown>): void;
}
| Parameter | Type | Description |
|---|---|---|
message | string | The log record body |
metadata | Record<string, unknown> (optional) | Structured context, serialized reliably |
All four methods are synchronous and return void; the level sets the record’s severity.
How metadata is carried
Metadata is serialized into a single JSON attribute on the log record (tray.log.meta) rather than exploded into one attribute per key. This keeps attribute cardinality bounded while preserving the full object for display and search.
Correlation with the execution trace
Every record carries the trace and span IDs of the active execution span, plus resource attributes identifying the tenant: executionId, projectId, workspaceId, organizationId, functionPath, and method. In production these are stamped server side at ingest from the verified execution token, so nothing the function process claims about identity is trusted. The dashboard interleaves log records with auto-instrumented spans into one time-ordered timeline per execution, filterable by any of those attributes.
Dev versus production transport
The code is identical in both environments; the runtime picks the transport. Under helix dev, ctx.log pretty-prints to the terminal (colored level, timestamp, message, and metadata inline) with no export. In deployed Lambda mode, records are exported as OTLP/protobuf through a batching processor to the Helix ingest endpoint, authenticated per invocation with the short-lived execution token, and flushed at the end of every execution (on crash, whatever was buffered is flushed along with the error). Because the payload is standard OTLP, the same stream can also be pointed at any OTel-compatible backend.
Example
// functions/api/v1/sync.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
export const input = z.object({
userId: z.string(),
});
export default defineFunction<typeof input>(async (ctx) => {
ctx.log.info('Starting sync', { userId: ctx.input.userId });
const contacts = await ctx.http
.authed('salesforce_prod')
.get('{{instance_url}}/services/data/v59.0/query', {
searchParams: { q: 'SELECT Id, Name FROM Contact LIMIT 10' },
})
.json<{ records: unknown[] }>();
ctx.log.info('Fetched contacts', { count: contacts.records.length });
if (contacts.records.length === 0) {
ctx.log.warn('Nothing to sync');
}
return { synced: contacts.records.length };
});
The execution timeline, auto-instrumented spans, console.log capture limits, and helix logs filtering are covered in the observability guide.