Logging and Sinks in TypeScript SDK
A complete sample for setting up the instrumentation for the different components of the SDK is available on our samples repo.
Logging from Activities
Activities run in the standard Node.js environment and can use any Node.js logger.
Inject Activity context via interceptor and log all Activity executions
instrumentation/src/activities/interceptors.ts
- TypeScript
- JavaScript
import { Context } from '@temporalio/activity';
import { ActivityInboundCallsInterceptor, ActivityExecuteInput, Next } from '@temporalio/worker';
import { Logger } from 'winston';
/** An Activity Context with an attached logger */
export interface ContextWithLogger extends Context {
logger: Logger;
}
/** Get the current Activity context with an attached logger */
export function getContext(): ContextWithLogger {
return Context.current() as ContextWithLogger;
}
/** Logs Activity executions and their duration */
export class ActivityInboundLogInterceptor implements ActivityInboundCallsInterceptor {
public readonly logger: Logger;
constructor(ctx: Context, logger: Logger) {
this.logger = logger.child({
activity: ctx.info,
});
// Set a logger instance on the current Activity Context to provide
// contextual logging information to each log entry generated by the Activity.
(ctx as ContextWithLogger).logger = this.logger;
}
async execute(input: ActivityExecuteInput, next: Next<ActivityInboundCallsInterceptor, 'execute'>): Promise<unknown> {
let error: any = undefined;
const startTime = process.hrtime.bigint();
try {
return await next(input);
} catch (err: any) {
error = err;
throw err;
} finally {
const durationNanos = process.hrtime.bigint() - startTime;
const durationMs = Number(durationNanos / 1_000_000n);
if (error) {
this.logger.error('activity failed', { error, durationMs });
} else {
this.logger.debug('activity completed', { durationMs });
}
}
}
}
import { Context } from '@temporalio/activity';
/** Get the current Activity context with an attached logger */
export function getContext() {
return Context.current();
}
/** Logs Activity executions and their duration */
export class ActivityInboundLogInterceptor {
logger;
constructor(ctx, logger) {
this.logger = logger.child({
activity: ctx.info,
});
// Set a logger instance on the current Activity Context to provide
// contextual logging information to each log entry generated by the Activity.
ctx.logger = this.logger;
}
async execute(input, next) {
let error = undefined;
const startTime = process.hrtime.bigint();
try {
return await next(input);
}
catch (err) {
error = err;
throw err;
}
finally {
const durationNanos = process.hrtime.bigint() - startTime;
const durationMs = Number(durationNanos / 1000000n);
if (error) {
this.logger.error('activity failed', { error, durationMs });
}
else {
this.logger.debug('activity completed', { durationMs });
}
}
}
}
Use the injected logger from an Activity
instrumentation/src/activities/index.ts
- TypeScript
- JavaScript
import { getContext } from './interceptors';
export async function greet(name: string): Promise<string> {
const { logger } = getContext();
logger.info('Log from activity', { name });
return `Hello, ${name}!`;
}
import { getContext } from './interceptors';
export async function greet(name) {
const { logger } = getContext();
logger.info('Log from activity', { name });
return `Hello, ${name}!`;
}
Logging from Workflows with Workflow Sinks
Logging from Workflows is tricky for two reasons:
- Workflows run in a sandboxed environment and cannot do any I/O.
- Workflow code might get replayed at any time, generating duplicate log messages.
To work around these limitations, we recommend using the Sinks feature in the TypeScript SDK. Sinks enable one-way export of logs, metrics, and traces from the Workflow isolate to the Node.js environment.
Sinks are written as objects with methods. Similar to Activities, they are declared in the Worker and then proxied in Workflow code, and it helps to share types between both.
Comparing Sinks, Activities and Interceptors
- Sink functions don't return any value back to the Workflow and cannot not be awaited.
- Sink calls are not recorded in Workflow histories (no timeouts or retries).
- Sink functions are always run on the same Worker that runs the Workflow they are called from.
Declaring the Sink Interface
Explicitly declaring a Sink's interface is optional, but is useful for ensuring type safety in subsequent steps:
packages/test/src/workflows/definitions.ts
- TypeScript
- JavaScript
import { Sinks } from '@temporalio/workflow';
export interface LoggerSinks extends Sinks {
logger: {
info(message: string): void;
};
}
// Not required in JavaScript
Implementing Sinks
Implementing Sinks is a two-step process.
Implement and inject the Sink function into a Worker
- TypeScript
- JavaScript
import { Worker, InjectedSinks } from '@temporalio/worker';
import { LoggerSinks } from './workflows';
async function main() {
const sinks: InjectedSinks<LoggerSinks> = {
logger: {
info: {
fn(workflowInfo, message) {
console.log('workflow: ', workflowInfo.runId, 'message: ', message);
},
callDuringReplay: false, // The default
},
},
};
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
taskQueue: 'logging-sinks',
sinks,
});
await worker.run();
console.log('Worker gracefully shutdown');
}
main().then(
() => void process.exit(0),
(err) => {
console.error(err);
process.exit(1);
}
);
import { Worker } from '@temporalio/worker';
async function main() {
const sinks = {
logger: {
info: {
fn(workflowInfo, message) {
console.log('workflow: ', workflowInfo.runId, 'message: ', message);
},
callDuringReplay: false, // The default
},
},
};
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
taskQueue: 'logging-sinks',
sinks,
});
await worker.run();
console.log('Worker gracefully shutdown');
}
main().then(() => void process.exit(0), (err) => {
console.error(err);
process.exit(1);
});
- Sink function implementations are passed as an object into WorkerOptions
- You can specify whether you want the injected function to be called during Workflow replay by setting the
callDuringReplay
boolean option.
Proxy and call a Sink function from a Workflow
packages/test/src/workflows/log-sample.ts
- TypeScript
- JavaScript
import * as wf from '@temporalio/workflow';
import { LoggerSinks } from './definitions';
const { logger } = wf.proxySinks<LoggerSinks>();
export async function logSampleWorkflow(): Promise<void> {
logger.info('Workflow execution started');
}
import * as wf from '@temporalio/workflow';
const { logger } = wf.proxySinks();
export async function logSampleWorkflow() {
logger.info('Workflow execution started');
}
Some important features of the InjectedSinkFunction interface:
- Injected WorkflowInfo argument: The first argument of a Sink function implementation is a
workflowInfo
object that contains useful metadata. - Limited arguments types: The remaining Sink function arguments are copied between the sandbox and the Node.js environment using the structured clone algorithm.
- No return value: To prevent breaking determinism, Sink functions cannot return values to the Workflow.
Advanced: Performance considerations and non-blocking Sinks
The injected sink function contributes to the overall Workflow Task processing duration.
- If you have a long-running sink function, such as one that tries to communicate with external services, you might start seeing Workflow Task timeouts.
- The effect is multiplied when using
callDuringReplay: true
and replaying long Workflow histories because the Workflow Task timer starts when the first history page is delivered to the Worker.
Logging in Workers and Clients
The Worker comes with a default logger which defaults to log any messages with level INFO
and higher to STDERR
using console.error
.
There are 5 levels in total: TRACE
, DEBUG
, INFO
, WARN
, and ERROR
.
The reason we only offer a default logger is to minimize Worker dependencies and allow SDK users to bring their own logger.
Customizing the default logger
Temporal ships a DefaultLogger
that implements the basic interface:
Example: Set up the DefaultLogger to only log messages with level WARN and higher
- TypeScript
- JavaScript
import { Runtime, DefaultLogger } from '@temporalio/worker';
const logger = new DefaultLogger('WARN', ({ level, message }) => {
console.log(`Custom logger: ${level} — ${message}`);
});
Runtime.install({ logger });
import { Runtime, DefaultLogger } from '@temporalio/worker';
const logger = new DefaultLogger('WARN', ({ level, message }) => {
console.log(`Custom logger: ${level} — ${message}`);
});
Runtime.install({ logger });
Example: Accumulate logs for testing/reporting
- TypeScript
- JavaScript
import { DefaultLogger, LogEntry } from '@temporalio/worker';
const logs: LogEntry[] = [];
const logger = new DefaultLogger('TRACE', (entry) => logs.push(entry));
log.debug('hey', { a: 1 });
log.info('ho');
log.warn('lets', { a: 1 });
log.error('go');
import { DefaultLogger } from '@temporalio/worker';
const logs = [];
const logger = new DefaultLogger('TRACE', (entry) => logs.push(entry));
log.debug('hey', { a: 1 });
log.info('ho');
log.warn('lets', { a: 1 });
log.error('go');
The log levels are listed here in increasing order of severity.
Using a custom logger
A common logging use case is logging to a file to be picked up by a collector like the Datadog Agent.
- TypeScript
- JavaScript
import { Runtime } from '@temporalio/worker';
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new transports.File({ filename: '/path/to/worker.log' })],
});
Runtime.install({ logger });
import { Runtime } from '@temporalio/worker';
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new transports.File({ filename: '/path/to/worker.log' })],
});
Runtime.install({ logger });
Metrics
For information about metrics, see the Metrics section on the Deploy Checklist page.
OpenTelemetry tracing
The interceptors-opentelemetry
sample shows how to use the SDK's built-in OpenTelemetry tracing to trace everything from starting a Workflow to Workflow Execution to running an Activity from that Workflow.
The built-in tracing uses protobuf message headers (like this one when starting a Workflow) to propagate the tracing information from the client to the Workflow and from the Workflow to its successors (when Continued As New), children, and Activities. All of these executions are linked with a single trace identifier and have the proper parent->child span relation.
Tracing is compatible between different Temporal SDKs as long as compatible context propagators are used.
Context propagation
The TypeScript SDK uses the global OpenTelemetry propagator.
To extend the default (Trace Context and Baggage propagators) to also include the Jaeger propagator, follow these steps:
npm i @opentelemetry/propagator-jaeger
At the top level of your Workflow code, add the following lines:
import { propagation } from '@opentelemetry/api';
import {
CompositePropagator,
W3CTraceContextPropagator,
W3CBaggagePropagator,
} from '@opentelemetry/core';
import { JaegerPropagator } from '@opentelemetry/propagator-jaeger';
propagation.setGlobalPropagator(
new CompositePropagator({
propagators: [
new W3CTraceContextPropagator(),
new W3CBaggagePropagator(),
new JaegerPropagator(),
],
})
);
Similarly, you can customize the OpenTelemetry NodeSDK
propagators by following the instructions in the Initialize the SDK section of the README.