@herrromich/az-functions
v1.1.5
Published
Extensions for NodeJS Azure Functions
Downloads
920
Readme
@herrromich/az-functions
az-functions extension for Azure Functions in Node.js
Overview
az-functions extends the Azure Functions programming model for Node.js (v4) by introducing a powerful structure for building scalable and maintainable serverless applications. It integrates several key concepts:
- Inversion of Control (IoC) Container: Using InversifyJS to manage dependencies and ensure proper decoupling of components.
- TypeScript Decorators: Declaratively define HTTP controllers, Event Hub handlers, and their arguments using decorators.
- Input Validation: Integrating the Zod validation library to ensure that all inputs (e.g., HTTP request bodies, Event Hub messages) are properly validated before processing.
- OpenAPI Code-First Generation: Automatically generating OpenAPI (Swagger) definitions based on the controller code, following a code-first approach.
- Structured Logging: Built-in logging via Winston with optional OpenTelemetry / Application Insights export.
Installation
pnpm add @herrromich/az-functions @azure/functions inversify reflect-metadata zod @asteasolutions/zod-to-openapinpm install @herrromich/az-functions @azure/functions inversify reflect-metadata zod @asteasolutions/zod-to-openapiyarn add @herrromich/az-functions @azure/functions inversify reflect-metadata zod @asteasolutions/zod-to-openapi
@azure/functions,inversify,reflect-metadata,zodand@asteasolutions/zod-to-openapiare peer dependencies and must be installed alongside the package.
az-functions uses TypeScript decorators.
Configure your tsconfig.json accordingly:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Quick Start
Step 1. Initialize reflect-metadata and optionally Zod for OpenAPI
// src/init.ts
import 'reflect-metadata';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
extendZodWithOpenApi(z);Step 2. Define trigger handler classes
Define your HTTP controllers and/or Event Hub handlers using decorators (see sections below).
Step 3. Start the platform
Call startPlatform with a PlatformConfiguration object.
The framework creates and manages the IoC container internally — you provide your ContainerModule[] and trigger handler classes:
// src/index.ts
import './init';
import { startPlatform, OtelConfiguration } from '@herrromich/az-functions';
import { OrdersController } from './controllers/orders.controller';
import { TruckTelemetryHandler } from './handlers/truck-telemetry.handler';
import { AppConfigModule } from './modules/app-config';
import { PersistenceModule } from './modules/persistence';
import { SecurityModule } from './modules/security';
import { ORDERS_REST_APPLICATION } from './applications/orders-api.application';
const otelConfiguration: OtelConfiguration | undefined =
process.env.APPLICATIONINSIGHTS_CONNECTION_STRING !== undefined
? {
applicationInsightsConnectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING,
serviceName: process.env.WEBSITE_DEPLOYMENT_ID,
serviceVersion: '1.0.0',
serviceInstanceId: process.env.WEBSITE_INSTANCE_ID,
}
: undefined;
startPlatform({
triggerHandlerClasses: [OrdersController, TruckTelemetryHandler],
restApplications: [ORDERS_REST_APPLICATION],
modules: [AppConfigModule, PersistenceModule, SecurityModule],
loggerConfiguration: {
otelConfiguration,
},
});[!WARNING] If you use a bundler like webpack, make sure the
initimport is at the very top of your entry module.
PlatformConfiguration
| Property | Type | Description |
|--------------------------|--------------------------|------------------------------------------------------------------------|
| triggerHandlerClasses | TriggerHandlerClass[] | HTTP controller and Event Hub handler classes to register. |
| restApplications | RestApplication[] | Optional REST application definitions (OpenAPI config, context path). |
| modules | ContainerModule[] | Inversify container modules loaded into the platform container. |
| loggerConfiguration | LoggerConfiguration | Optional logger and OpenTelemetry configuration. |
HTTP Controller
Define a REST Application (optional)
A RestApplication defines the OpenAPI metadata and base context path for a group of controllers.
If omitted, the platform generates a default OpenAPI definition with available endpoints.
// src/applications/orders-api.application.ts
import { RestApplication } from '@herrromich/az-functions';
export const ORDERS_API = 'orders-api';
export const ORDERS_REST_APPLICATION: RestApplication = {
name: ORDERS_API,
context: '/orders-api',
openApiConfig: {
openapi: '3.0.1',
info: {
title: 'Orders API',
version: '1.0.0',
description: 'API for Orders Management',
},
tags: [
{ name: 'Orders', description: 'CRUD operations for orders' },
],
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
},
};Define an HTTP Controller
Use @HttpController (class decorator) and HTTP method decorators (@Get, @Post, @Put, @Patch, @Delete, @Head) to define routes.
Parameter decorators inject validated request data:
// src/controllers/orders.controller.ts
import { z } from 'zod';
import { inject } from 'inversify';
import {
AuthContext,
AuthCtx,
BadRequestError,
Body,
Get,
HttpController,
HttpDirectResponseBuilder,
LOGGER_FACTORY,
LoggerFactory,
NotFoundError,
NumberSchema,
Post,
QueryParam,
} from '@herrromich/az-functions';
import { ORDERS_API } from './orders-api.application';
const OrderDtoSchema = z.object({
id: z.string().uuid(),
customerName: z.string(),
total: z.number(),
}).openapi('OrderDto');
type OrderDto = z.infer<typeof OrderDtoSchema>;
const OrdersResponseSchema = z.object({
items: z.array(OrderDtoSchema),
total: z.number(),
}).openapi('OrdersResponse');
type OrdersResponse = z.infer<typeof OrdersResponseSchema>;
const OrderCreateRequestSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({ productId: z.string(), quantity: z.number() })),
}).openapi('OrderCreateRequest');
type OrderCreateRequest = z.infer<typeof OrderCreateRequestSchema>;
@HttpController({
application: ORDERS_API,
path: '/orders',
tags: ['Orders'],
})
export class OrdersController {
private readonly logger;
constructor(
@inject(LOGGER_FACTORY) loggerFactory: LoggerFactory,
private readonly ordersRepository: OrdersRepository,
) {
this.logger = loggerFactory();
}
@Get({
description: 'Get orders with pagination',
directResponse: {
status: 200,
description: 'Paginated list of orders',
jsonContent: { schema: OrdersResponseSchema },
},
})
async getOrders(
@AuthCtx() authContext: AuthContext,
@QueryParam({
name: 'offset',
schema: NumberSchema.optional().openapi({ description: 'Items to skip' }),
})
offset: number | undefined,
@QueryParam({
name: 'limit',
schema: NumberSchema.optional().openapi({ description: 'Max items to return' }),
})
limit: number | undefined,
): Promise<OrdersResponse> {
this.logger.info('Fetching orders.');
// ... fetch and return orders
}
@Post({
description: 'Create a new order',
directResponse: {
status: 201,
description: 'Order created successfully',
jsonContent: { schema: OrderDtoSchema },
},
responses: {
'400': { description: 'Bad request error' },
},
})
async createOrder(
@AuthCtx() authContext: AuthContext,
@Body({
description: 'Order creation request',
schema: OrderCreateRequestSchema,
})
request: OrderCreateRequest,
): Promise<HttpResponseInit> {
this.logger.info('Creating order.');
const order = await this.ordersRepository.create(request);
return HttpDirectResponseBuilder.builder<OrderDto>()
.header('Location', `/orders/${order.id}`)
.jsonBody(order)
.build();
}
}HTTP Decorators Reference
Class Decorator
| Decorator | Config | Description |
|----------------------------|--------------------|--------------------------------------|
| @HttpController(config) | ControllerConfig | Marks a class as an HTTP controller. |
ControllerConfig:
| Property | Type | Description |
|---------------|--------------|--------------------------------------------------------------|
| application | string? | Name of the REST application this controller belongs to. |
| path | string | Base path for all operations in this controller. |
| tags | string[]? | OpenAPI tags for this controller. |
Method Decorators
| Decorator | HTTP Method |
|--------------------|-------------|
| @Get(config?) | GET |
| @Post(config?) | POST |
| @Put(config?) | PUT |
| @Patch(config?) | PATCH |
| @Delete(config?) | DELETE |
| @Head(config?) | HEAD |
Each accepts a ControllerOperationConfig (or ControllerRequestBodyOperationConfig for POST/PUT/PATCH) with properties like path, description, summary, directResponse, responses, authLevel, etc.
Parameter Decorators
| Decorator | Description |
|------------------------|----------------------------------------------------------------------------------------------|
| @Body(config) | Injects the validated request body. Config: { schema, description?, required?, example? } |
| @QueryParam(config) | Injects a validated query parameter. Config: { name, schema? } |
| @PathParam(config) | Injects a validated path parameter. Config: { name, schema? } |
| @HeaderParam(config) | Injects a validated header value. Config: { name, schema? } |
| @Request() | Injects the raw HttpRequest object. |
| @AuthCtx() | Injects the AuthContext with the authenticated principal. |
HTTP Error Classes
Throw these from controller methods to return appropriate HTTP error responses:
| Class | Status Code |
|-----------------------|-------------|
| BadRequestError | 400 |
| UnauthorizedError | 401 |
| ForbiddenError | 403 |
| NotFoundError | 404 |
| InternalServerError | 500 |
Security
HTTP-level authentication/authorization is declarative: you describe who may call an operation using the
same OpenAPI security/securitySchemes vocabulary you already use for documentation, and the framework
resolves and enforces it at request time — no manual header parsing or auth checks in your controller code.
1. Declare a security scheme and require it
Security schemes are declared once per RestApplication (openApiConfig.components.securitySchemes) and
referenced by name from either the application (applies to every operation) or an individual operation
(overrides the application-level requirement for that operation only):
export const ORDERS_REST_APPLICATION: RestApplication = {
name: ORDERS_API,
context: '/orders-api',
openApiConfig: {
// ...
security: [{ bearerAuth: [] }], // required for every operation unless overridden per-operation
components: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
},
},
},
};
class OrdersController {
@Get({
description: 'Get order by ID',
// Overrides the application-level requirement for just this operation, and requires a scope:
security: [{ bearerAuth: ['Orders.Read'] }],
directResponse: { status: 200, description: 'Order', jsonContent: { schema: OrderDtoSchema } },
})
async getOrderById(@PathParam({ name: 'orderId', schema: IdDtoSchema }) orderId: string): Promise<OrderDto> {
// ...
}
}securityis an array of OR-ed requirement objects — any one of them satisfies the operation.- Each requirement object can list multiple scheme keys — all of them must succeed (AND) for that requirement to be satisfied.
- The array of strings per scheme is the list of scopes required from the resolved principal.
- Omitting
securityon both the operation and the application leaves the operation unauthenticated — the framework injects a defaultAuthContext(principal: null,principals: [],scopes: []).
2. Implement an AuthenticationService per scheme
Each scheme name used in securitySchemes must resolve to an AuthenticationService bound under
AUTHENTICATION_SERVICE, scoped to that scheme name via .whenNamed(...):
import { AuthenticationError, AuthenticationService, AUTHENTICATION_SERVICE } from '@herrromich/az-functions';
import { HttpRequest } from '@azure/functions';
import { ContainerModule, injectable } from 'inversify';
@injectable()
export class BearerAuthenticationService implements AuthenticationService {
async authenticate(request: HttpRequest): Promise<Principal> {
const authHeader = request.headers.get('authorization');
if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {
throw new AuthenticationError('Invalid or missing Authorization Bearer token');
}
const token = authHeader.split(' ')[1] ?? '';
// ...verify the token (e.g. via jwks-rsa/jsonwebtoken)...
const scopes: string[] = []; // resolved scopes/permissions
return { subject: 'user-id', type: 'user', scheme: 'bearer', scopes };
}
}
export const SecurityModule = new ContainerModule(({ bind }) => {
bind(AUTHENTICATION_SERVICE).to(BearerAuthenticationService).whenNamed('bearerAuth');
});- Throw
AuthenticationError(not a genericError) when the request simply isn't authenticated — the framework turns this into a401 Unauthorizedresponse. Any other error propagates and is treated as an unexpected failure (500). - Register the module in
startPlatform({ modules: [...] })like any otherContainerModule. - The same scheme name can be bound differently per
RestApplicationby additionally tagging the binding, if different applications need different authentication logic under the same scheme name — not needed for the common case of one implementation per scheme name across the whole app.
3. Principal and AuthContext
An AuthenticationService.authenticate(...) call resolves a Principal:
interface Principal {
subject: string; // unique identifier of the principal (e.g. user id)
type: string; // e.g. "user", "service"
scheme: string; // auth scheme used (e.g. "bearer")
scopes: string[]; // scopes/permissions granted to the principal
meta?: Record<string, unknown>;
}When an operation's requirement object has multiple schemes (AND), the resulting Principals are merged
into a single AuthContext:
interface AuthContext {
principal: Principal | null; // the (first) resolved principal, or null when unauthenticated
principals: Principal[]; // every principal resolved for the operation (AND-composed schemes)
scopes: string[]; // union of scopes across all resolved principals
}By default, merging is strict: all AND-composed principals must share the same subject/type, or an
AuthenticationError is thrown (surfaced as 401). Bind a custom PrincipalMergeService under
PRINCIPAL_MERGE_SERVICE to override this behavior (e.g. to allow combining a user principal with a
service principal under different rules).
Required scopes (from security: [{ scheme: [...scopes] }]) are checked against the resolved principal's
scopes after authentication succeeds — a missing scope results in a 403 Forbidden response rather
than 401, since the caller is authenticated but not authorized for that operation.
4. Access the AuthContext in a controller
Inject it into an operation method with @AuthCtx():
class OrdersController {
async getOrderById(
@AuthCtx() authContext: AuthContext,
@PathParam({ name: 'orderId', schema: IdDtoSchema }) orderId: string,
): Promise<OrderDto> {
const subject = authContext.principal?.subject;
// ...
}
}Outside a controller method — e.g. in a mapper or a domain service that still needs the current caller's
identity — inject SecurityContext instead and call getAuthentication(), which reads the same
AuthContext from the active PlatformContextManager scope:
import { SecurityContext } from '@herrromich/az-functions';
import { inject, injectable } from 'inversify';
@injectable()
export class OrdersMapper {
constructor(@inject(SecurityContext) private readonly securityContext: SecurityContext) {}
toDto(order: Order) {
const authContext = this.securityContext.getAuthentication();
// ...
}
}5. Error responses
| Condition | Response |
|--------------------------------------------------------------------------|----------|
| No AuthenticationService for a requirement's scheme(s) succeeds | 401 Unauthorized |
| At least one scheme succeeds, but the resolved principal is missing a required scope | 403 Forbidden |
These are enforced by the framework itself before your operation method runs — you don't need to (and
shouldn't) throw UnauthorizedError/a forbidden response for missing/insufficient authentication yourself;
reserve throwing UnauthorizedError/BadRequestError/NotFoundError in your own code for
business-logic-level authorization/validation failures instead (see the "HTTP Error Classes" section above).
HttpDirectResponseBuilder
Use when you need full control over the HTTP response (custom status, headers):
return HttpDirectResponseBuilder.builder<OrderDto>()
.header('Location', `/orders/${order.id}`)
.jsonBody(orderDto)
.build();OpenAPI Generation
Once the application is built, you can generate the OpenAPI specification in a special mode:
PLATFORM_MODE=print-open-api node dist/index.jsThis outputs the OpenAPI JSON and YAML files for each registered REST application without starting the Azure Functions host.
| Environment Variable | Default | Description |
|-----------------------|-----------------------------|--------------------------------------------------------------------------------------------------------|
| PLATFORM_MODE | start | Set to print-open-api to generate OpenAPI definitions instead of starting the host. |
| OPEN_API_PRINT_PATH | dist/open-api-definitions | Directory where generated .json and .yaml files are written. Resolved relative to process.cwd(). |
Example with a custom output path:
PLATFORM_MODE=print-open-api OPEN_API_PRINT_PATH=./docs/api node dist/index.jsEvent Hub Handler
Define an Event Hub Handler
Use @EventHubHandler (class decorator) to specify the Event Hub connection and name.
Use @OnEventHubTrigger (method decorator) to define the trigger method:
// src/handlers/truck-telemetry.handler.ts
import { z } from 'zod';
import { inject } from 'inversify';
import {
EventHubHandler,
EventHubMessageWrapper,
LOGGER_FACTORY,
LoggerFactory,
Messages,
OnEventHubTrigger,
} from '@herrromich/az-functions';
const TelemetryPayloadSchema = z.object({
deviceId: z.string(),
temperature: z.number(),
timestamp: z.string(),
});
type TelemetryPayload = z.infer<typeof TelemetryPayloadSchema>;
@EventHubHandler({
connection: 'EventHubConnection',
eventHubName: 'telemetry',
})
export class TruckTelemetryHandler {
private readonly logger;
constructor(@inject(LOGGER_FACTORY) loggerFactory: LoggerFactory) {
this.logger = loggerFactory();
}
@OnEventHubTrigger({ cardinality: 'many' })
async handleTelemetry(
@Messages({
withPayload: TelemetryPayloadSchema,
withEventData: true,
})
messages: EventHubMessageWrapper<TelemetryPayload, undefined, undefined, true>[],
): Promise<void> {
this.logger.info(`Received ${messages.length} telemetry messages`, {
messages: messages.map(msg => ({
payload: msg.payload,
enqueuedTimeUtc: msg.eventData.enqueuedTimeUtc,
})),
});
}
}Event Hub Decorators Reference
Class Decorator
| Decorator | Config | Description |
|-----------------------------|-------------------------|-----------------------------------------|
| @EventHubHandler(config) | EventHubHandlerConfig | Marks a class as an Event Hub handler. |
EventHubHandlerConfig:
| Property | Type | Description |
|----------------|----------|----------------------------------------------------------------|
| connection | string | App setting name containing the Event Hub connection string. |
| eventHubName | string | Name of the Event Hub to listen to. |
Method Decorator
| Decorator | Config | Description |
|--------------------------------|---------------------------|----------------------------------------------------------------|
| @OnEventHubTrigger(config?) | OnEventHubTriggerConfig | Marks the method that handles incoming Event Hub messages. |
OnEventHubTriggerConfig:
| Property | Type | Default | Description |
|----------------|---------------------|------------|--------------------------------------------------|
| triggerId | string? | — | Unique identifier for the trigger. |
| consumerGroup| string? | $Default | Consumer group to use. |
| cardinality | 'one' \| 'many'? | 'many' | Whether to receive a single message or a batch. |
| extraInputs | FunctionInput[]? | — | Additional Azure Functions inputs. |
| extraOutputs | FunctionOutput[]? | — | Additional Azure Functions outputs. |
Parameter Decorators
| Decorator | Cardinality | Description |
|----------------------|-------------|--------------------------------------------------|
| @Message(config?) | one | Injects a single validated message wrapper. |
| @Messages(config?) | many | Injects an array of validated message wrappers. |
| @RawMessage() | one | Injects the raw unvalidated message. |
| @RawMessages() | many | Injects the raw unvalidated message array. |
EventHubTriggerMessageArgConfig (for @Message / @Messages):
| Property | Type | Description |
|-------------------------|------------|------------------------------------------------------------------------------|
| withPayload | ZodType? | Schema to validate the message payload. |
| withProperties | ZodType? | Schema to validate custom properties. |
| withSystemProperties | ZodType? | Schema to validate system properties. |
| withEventData | true? | Include Event Hub event data (offset, sequence number, enqueued time, etc.). |
EventHubMessageWrapper<PAYLOAD, PROPERTIES, SYSTEM_PROPERTIES, EVENT_DATA>
The wrapper type provides typed access to message components:
message.payload // PAYLOAD — validated message body
message.properties // PROPERTIES — custom properties (if schema provided)
message.systemProperties // SYSTEM_PROPERTIES — system properties (if schema provided)
message.eventData // EventHubEventData — event metadata (if withEventData: true)
message.valid // boolean — whether validation succeededStartup Service
Register a startup hook that runs when the Azure Functions host starts:
import { IStartupService, STARTUP_SERVICE } from '@herrromich/az-functions';
import { injectable } from 'inversify';
@injectable()
export class MyStartupService implements IStartupService {
async startup(): Promise<void> {
// Run migrations, warm caches, etc.
}
}Register it in a container module:
import { ContainerModule } from 'inversify';
import { STARTUP_SERVICE } from '@herrromich/az-functions';
import { MyStartupService } from './startup.service';
export const StartupModule = new ContainerModule(({ bind }) => {
bind(STARTUP_SERVICE).to(MyStartupService);
});Logging
The framework provides a structured logging system built on Winston. It supports automatic logger naming, hierarchical log level configuration, per-invocation context metadata, and optional OpenTelemetry export.
Basic Usage
Inject LOGGER_FACTORY and call it to create a logger scoped to your class:
import { inject } from 'inversify';
import { LOGGER_FACTORY, LoggerFactory } from '@herrromich/az-functions';
@injectable()
export class OrdersService {
private readonly logger;
constructor(@inject(LOGGER_FACTORY) loggerFactory: LoggerFactory) {
this.logger = loggerFactory(); // auto-detects logger name from the call stack
}
doWork() {
this.logger.info('Processing started.');
this.logger.debug('Details:', { someContext: 'value' });
}
}You can also provide an explicit logger name:
this.logger = loggerFactory('my-app.orders.OrdersService');Log Levels
The following log levels are available, ordered from highest to lowest severity:
| Level | Priority | Description |
|-----------|----------|------------------------|
| error | 0 | Error conditions |
| warn | 1 | Warning conditions |
| info | 2 | Informational messages |
| http | 3 | HTTP request logging |
| verbose | 4 | Verbose output |
| debug | 5 | Debug information |
| silly | 6 | Trace-level detail |
Logger Interface
The Logger type exposes a method for each log level.
Each method accepts a message and optional metadata:
logger.error('Operation failed', { orderId, error });
logger.warn('Retrying request', { attempt: 3 });
logger.info('Order created', { orderId });
logger.verbose('Cache hit', { key });
logger.debug('Query executed', { sql, params });
logger.silly('Entering method', { args });Default Log Level
Set the default log level for all loggers via LoggerConfiguration:
startPlatform({
// ...
loggerConfiguration: {
defaultLogLevel: 'debug', // default is 'info' if omitted
},
});The default log level is set once at startup and applies to all loggers that don't have a specific level configured via LogLevelProvider.
It can be read from the container using the DEFAULT_LOG_LEVEL service identifier (e.g., for use in a LogLevelProvider constructor).
Per-Logger Log Level Configuration
For fine-grained control, implement a LogLevelProvider and bind it to LOG_LEVEL_PROVIDER.
The provider receives the logger name and returns the applicable log level (or undefined to fall back to the default):
import { LogLevel, LogLevelProvider, LOG_LEVEL_PROVIDER } from '@herrromich/az-functions';
import { injectable } from 'inversify';
@injectable()
class MyLogLevelProvider implements LogLevelProvider {
getLogLevel(loggerName?: string): LogLevel | undefined {
if (loggerName?.startsWith('my-app.persistence')) {
return 'error'; // suppress noisy persistence logs
}
if (loggerName?.startsWith('my-app.orders')) {
return 'debug'; // verbose logging for orders module
}
return undefined; // use default log level
}
}
export const LoggerModule = new ContainerModule(({ bind }) => {
bind(LOG_LEVEL_PROVIDER).to(MyLogLevelProvider);
});Hierarchical Log Levels with TrieSearchService
For applications with many loggers, use the built-in TrieSearchService to configure log levels in a hierarchical, prefix-based manner.
Logger names separated by . are matched using a trie — the longest matching prefix wins:
import {
DEFAULT_LOG_LEVEL,
LogLevel,
LogLevelProvider,
LOG_LEVEL_PROVIDER,
SYSTEM_LOGGER_NAME_PREFIX,
TrieSearchService,
} from '@herrromich/az-functions';
import { inject, optional } from 'inversify';
export class TrieSearchLogLevelProvider extends TrieSearchService<LogLevel> implements LogLevelProvider {
constructor(@inject(DEFAULT_LOG_LEVEL) @optional() defaultLogLevel: LogLevel) {
super('.', defaultLogLevel); // '.' is the separator for hierarchical names
this.set(SYSTEM_LOGGER_NAME_PREFIX, 'warn'); // platform internals: warn and above
this.set('my-app.persistence.kysely', 'error'); // Kysely SQL logs: errors only
this.set('my-app.orders', 'debug'); // orders module: debug and above
}
getLogLevel(loggerName: string | undefined): LogLevel | undefined {
return this.find(loggerName);
}
}
export const LoggerModule = new ContainerModule(({ bind }) => {
bind(TrieSearchLogLevelProvider).toSelf();
bind(LOG_LEVEL_PROVIDER).toService(TrieSearchLogLevelProvider);
});With this configuration:
my-app.orders.OrdersService→debug(matchesmy-app.ordersprefix)my-app.orders.OrdersMapper→debug(matchesmy-app.ordersprefix)my-app.persistence.kysely→error(exact match)my-app.persistence.repository→ default level (no specific prefix match)#az-functions.http-controller→warn(matches#az-functionsprefix)
Since TrieSearchService has set(), get(), and getAll() methods, you can also adjust log levels at runtime.
For example, by exposing an HTTP endpoint (see the example project for a full LogLevelsController implementation).
Automatic Logger Name Resolution
By default, loggerFactory() (called without arguments) uses LOGGER_NAME_PROVIDER to derive the logger name from the call stack.
Bind a custom LOGGER_NAME_PROVIDER to control how names are derived:
import { LOGGER_NAME_PROVIDER } from '@herrromich/az-functions';
export const LoggerModule = new ContainerModule(({ bind }) => {
bind(LOGGER_NAME_PROVIDER).toFactory(() => {
const regexp =
/^\s*at\s+(?:new\s+)?([A-Za-z0-9_$]+)\s+\(.*src[\\/](.+)\/[^\\/]+:\d+:\d+\)$/;
return (stackEntry?: string) => {
const stackLines = stackEntry?.split('\n') ?? [];
for (const line of stackLines) {
const match = regexp.exec(line.trim());
if (match) {
return `my-app.${match[2]?.replace(/\//g, '.')}.${match[1]}`;
}
}
};
});
});This produces logger names like my-app.shared.orders.OrdersService based on the file path and class name of the caller.
Context Logger Metadata
Use adjustContextLoggerMetadata to attach metadata to all log messages within the current invocation context.
Metadata is configured per log level, so you can include detailed data only at lower severity levels to avoid noise:
import {
adjustContextLoggerMetadata,
PLATFORM_CONTEXT_MANAGER,
} from '@herrromich/az-functions';
// Attach metadata scoped to the current invocation context
adjustContextLoggerMetadata(contextManager, {
error: {
// included in error-level messages — full diagnostic data
requestId,
userId,
requestBody,
},
warn: {
// included in warn-level messages — moderate detail
requestId,
userId,
},
silly: {
// included in silly-level messages — full trace data
requestId,
userId,
requestBody,
headers,
},
});The metadata is automatically merged with any existing context metadata. It is included in all subsequent log messages at the corresponding level within the same invocation.
SYSTEM_LOGGER_NAME_PREFIX
The constant SYSTEM_LOGGER_NAME_PREFIX ('#az-functions') identifies loggers used internally by the platform.
Use it when configuring log levels to control the verbosity of framework-internal logging separately from your application:
this.set(SYSTEM_LOGGER_NAME_PREFIX, 'warn'); // only warnings and errors from the frameworkWhen an internal (platform) class calls loggerFactory() without an explicit name, the name is auto-derived
from the call stack as ${SYSTEM_LOGGER_NAME_PREFIX}.<module-path>.<ClassName>, where <module-path> mirrors
the source folder structure inside the package (with / replaced by .) and <ClassName> is the constructor,
class, or function that requested the logger.
Because logger names follow this hierarchical, dot-separated structure, you can use TrieSearchService
(see above) to target a whole subsystem with a single prefix, or a specific class with a fully qualified name.
The following internal logger names are currently produced by the platform:
| Logger Name | Module / Component |
|--------------------------------------------------------------------------|--------------------------------------------------------|
| #az-functions.platform.AzurePlatform | Core platform bootstrap / trigger wiring |
| #az-functions.http-controller.HttpOperationsRegistrationService | HTTP operation registration with the Functions host |
| #az-functions.http-controller.HttpHandlerFactory | HTTP trigger handler creation |
| #az-functions.http-controller.OpenApiRegistrationService | OpenAPI route/document registration |
| #az-functions.http-controller.OpenApiDefinitionService | OpenAPI definition generation |
| #az-functions.http-controller.OpenApiPrintService | OpenAPI print (PLATFORM_MODE=print-open-api) mode |
| #az-functions.http-controller.security.AuthenticatorProvider | HTTP authenticator resolution |
| #az-functions.http-controller.security.OperationAuthenticationResolver | Per-operation authentication resolution |
| #az-functions.event-hub-handler.EventHubTriggersRegistrationService | Event Hub trigger registration with the Functions host |
| #az-functions.event-hub-handler.EventHubHandlerFactory | Event Hub trigger handler creation |
[!NOTE] This list reflects the classes that currently request a logger via
loggerFactory()without an explicit name. It may grow as the framework evolves — inspect the resolved logger name via your ownLogLevelProvideror log output if you need to confirm the exact name for your installed version.
Log Sanitization
Before log metadata is emitted, the platform sanitizes it to avoid leaking sensitive data and to keep log
entries bounded in size. Sanitization is applied automatically to every logged metadata object (e.g. the
second argument passed to logger.info(message, metadata), error cause/details, and HttpRequest /
HttpResponse instances) — you don't need to call anything yourself for the default behavior.
What sanitization does:
- Redacts sensitive headers.
authorization,cookieand any header starting withx-msare replaced with[REDACTED]when anHttpRequest/HttpResponseis logged. - Omits request/response bodies, replacing them with a placeholder like
[RequestBody<123Byte>]so payloads are never logged inline. - Truncates long strings to a maximum length (appending
...). - Truncates large arrays/sets/maps to a maximum number of elements (appending a
... more N itemsmarker). - Truncates objects with many keys to a maximum number of keys (adding a
__meta__entry describing how many keys were omitted). - Limits recursion depth for deeply nested objects, replacing anything beyond the limit with a short
placeholder (e.g.
[Object],[Array<5>]). - Truncates error stack traces to a maximum number of lines, while still recursing into
causeand (forAzFunctionsError)details. - Detects circular references, replacing repeated object references with
[Circular].
Default Limits
Limits are configured per log level via SanitizerOptions (maxDepth, maxTraceLength, maxArrayLength,
maxKeysCount, maxStringLength). Lower-severity/high-volume levels use tighter limits, while error and
silly are effectively unbounded so no diagnostic information is lost when you need it most:
| Log Level | maxDepth | maxTraceLength | maxArrayLength | maxKeysCount | maxStringLength |
|-----------|-----------:|-----------------:|------------------:|---------------:|-------------------:|
| error | unbounded | 10 (default) | unbounded | unbounded | unbounded |
| warn | 10 | 10 (default) | 20 (default) | 20 (default) | 250 (default) |
| info | 5 (default)| 10 (default) | 20 (default) | 20 (default) | 250 (default) |
| http | 5 | 10 (default) | 10 | 20 (default) | 250 (default) |
| verbose | 10 | 10 (default) | 20 (default) | 20 (default) | 250 (default) |
| debug | 20 | 10 (default) | 25 | 25 | 1000 |
| silly | unbounded | 10 (default) | unbounded | unbounded | unbounded |
[!NOTE] Cells marked "(default)" fall back to the base defaults used by
sanitizeMetadata(maxDepth: 5,maxTraceLength: 10,maxArrayLength: 20,maxKeysCount: 20,maxStringLength: 250) because that log level has no explicit override for that property.
Reconfiguring Sanitization
Override the defaults (per log level) via sanitizerOptions in LoggerConfiguration.
Only the levels/properties you want to change need to be specified — everything else keeps its default:
startPlatform({
// ...
loggerConfiguration: {
sanitizerOptions: {
// allow larger payloads to be logged at info level
info: { maxStringLength: 2000, maxArrayLength: 50 },
// reduce noise from warn-level logs even further
warn: { maxDepth: 3, maxKeysCount: 10 },
},
},
});OpenTelemetry / Application Insights
Pass otelConfiguration in loggerConfiguration to export logs and traces to Application Insights:
startPlatform({
// ...
loggerConfiguration: {
otelConfiguration: {
applicationInsightsConnectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING,
serviceName: 'my-service',
serviceVersion: '1.0.0',
},
},
});If no otelConfiguration is provided, the framework falls back to the built-in Azure Functions InvocationContext logger.
Registering Unsupported Azure Functions Triggers
For trigger types not yet supported by decorators (e.g., Cosmos DB, Timer), use the Azure Functions SDK directly.
To benefit from the platform's structured logging and context propagation, retrieve the PlatformContextManager and PlatformContextProvider from the platform container and wrap your handler execution:
import { app, InvocationContext } from '@azure/functions';
import {
Logger,
LOGGER_FACTORY,
LoggerFactory,
PLATFORM_CONTEXT_MANAGER,
PLATFORM_CONTEXT_PROVIDER,
startPlatform,
} from '@herrromich/az-functions';
// startPlatform returns the platform container
const platformContainer = startPlatform({ /* ... */ });
const contextManager = platformContainer.get(PLATFORM_CONTEXT_MANAGER);
const contextProvider = platformContainer.get(PLATFORM_CONTEXT_PROVIDER);
const loggerFactory = platformContainer.get(LOGGER_FACTORY);
const logger: Logger = loggerFactory('cosmosdb-handler');
app.cosmosDB('cosmosDbTrigger', {
connection: 'CosmosDBConnection',
containerName: 'my-container',
databaseName: 'my-database',
handler: async (documents: unknown[], context: InvocationContext) => {
// Wrap execution in the platform context for structured logging
return contextManager.runWith(
contextProvider.providePlatformContext(context),
async () => {
logger.info(`Received ${documents.length} documents from Cosmos DB`);
logger.debug('Processing documents', { documents });
// ... handle documents
},
);
},
});Key services available from the platform container:
| Service Identifier | Type | Description |
|-----------------------------|---------------------------|---------------------------------------------------------------------------------------------------------|
| PLATFORM_CONTEXT_MANAGER | PlatformContextManager | Manages the async context. Use runWith() to scope logging and context values to a handler invocation. |
| PLATFORM_CONTEXT_PROVIDER | PlatformContextProvider | Creates a PlatformContext from an InvocationContext. |
| LOGGER_FACTORY | LoggerFactory | Creates scoped Logger instances with structured logging support. |
Without wrapping in contextManager.runWith(), the logger will still work but won't have access to the invocation context (e.g., invocation ID, trigger metadata) for log correlation.
License
MIT
