autotel
Version:
Write Once, Observe Anywhere
670 lines (585 loc) • 17.6 kB
text/typescript
/**
* Safe baggage propagation with guardrails
*
* Provides type-safe baggage schemas with built-in protection against
* common pitfalls: high-cardinality values, PII leakage, and oversized payloads.
*
* @example Define a custom schema
* ```typescript
* import { createSafeBaggageSchema } from 'autotel/business-baggage';
*
* const OrderBaggage = createSafeBaggageSchema({
* orderId: { type: 'string' },
* customerId: { type: 'string', hash: true }, // Auto-hash for privacy
* priority: { type: 'enum', values: ['low', 'normal', 'high'] },
* });
*
* // Usage in traced function
* OrderBaggage.set(ctx, { orderId: 'ord-123', customerId: 'cust-456', priority: 'high' });
* const { orderId, priority } = OrderBaggage.get(ctx);
* ```
*
* @example Use pre-built BusinessBaggage
* ```typescript
* import { BusinessBaggage } from 'autotel/business-baggage';
*
* BusinessBaggage.set(ctx, { tenantId: 'acme', userId: 'user-123' });
* const { tenantId } = BusinessBaggage.get(ctx);
* ```
*
* @module
*/
import { context, propagation } from '@opentelemetry/api';
import type { TraceContext } from './trace-context';
// ============================================================================
// Types
// ============================================================================
/**
* Supported field types in baggage schema
*/
export type BaggageFieldType = 'string' | 'number' | 'boolean' | 'enum';
/**
* Field definition in a baggage schema
*/
export interface BaggageFieldDefinition {
/** Field type */
type: BaggageFieldType;
/** Maximum length for string values (default: 256) */
maxLength?: number;
/** Hash value before storing (for privacy) */
hash?: boolean;
/** Allowed values for enum type */
values?: readonly string[];
/** Default value if not provided */
defaultValue?: string | number | boolean;
/** Whether field is required */
required?: boolean;
/** Custom validation function */
validate?: (value: unknown) => boolean;
}
/**
* Options for creating a safe baggage schema
*/
export interface SafeBaggageOptions {
/** Maximum key length (default: 64) */
maxKeyLength?: number;
/** Maximum value length (default: 256) */
maxValueLength?: number;
/** Maximum total baggage size in bytes (default: 8192) */
maxTotalSize?: number;
/** Prefix for all keys (default: none) */
prefix?: string;
/** Hash high-cardinality values automatically */
hashHighCardinality?: boolean;
/** Detect and redact PII patterns */
redactPII?: boolean;
/** Allowed keys whitelist (others rejected) */
allowedKeys?: string[];
/** Custom error handler */
onError?: (error: BaggageError) => void;
}
/**
* Schema definition type - maps field names to definitions
*/
export type BaggageSchemaDefinition = Record<string, BaggageFieldDefinition>;
/**
* Inferred type from schema definition
*/
export type InferBaggageType<T extends BaggageSchemaDefinition> = {
[K in keyof T]?: T[K]['type'] extends 'string'
? string
: T[K]['type'] extends 'number'
? number
: T[K]['type'] extends 'boolean'
? boolean
: T[K]['type'] extends 'enum'
? T[K]['values'] extends readonly string[]
? T[K]['values'][number]
: string
: unknown;
};
/**
* Baggage error details
*/
export interface BaggageError {
type: 'validation' | 'size' | 'pii' | 'key_length' | 'value_length';
key: string;
message: string;
value?: unknown;
}
/**
* Safe baggage schema interface
*/
export interface SafeBaggageSchema<T extends BaggageSchemaDefinition> {
/**
* Get baggage values from context
*/
get(ctx?: TraceContext): Partial<InferBaggageType<T>>;
/**
* Set baggage values in context
* Returns new context with baggage (for context propagation)
*/
set(
ctx: TraceContext | undefined,
values: Partial<InferBaggageType<T>>,
): void;
/**
* Get a single baggage value
*/
getValue<K extends keyof T>(
key: K,
ctx?: TraceContext,
): InferBaggageType<T>[K] | undefined;
/**
* Set a single baggage value
*/
setValue<K extends keyof T>(
key: K,
value: InferBaggageType<T>[K],
ctx?: TraceContext,
): void;
/**
* Clear all schema baggage values
*/
clear(ctx?: TraceContext): void;
/**
* Get all baggage as headers for propagation
*/
toHeaders(ctx?: TraceContext): Record<string, string>;
/**
* Restore baggage from headers
*/
fromHeaders(headers: Record<string, string>, ctx?: TraceContext): void;
}
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_MAX_KEY_LENGTH = 64;
const DEFAULT_MAX_VALUE_LENGTH = 256;
const DEFAULT_MAX_TOTAL_SIZE = 8192;
// PII patterns to detect and redact
const PII_PATTERNS = [
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, // Email
/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/, // Phone (US)
/\b\d{3}[-]?\d{2}[-]?\d{4}\b/, // SSN
/\b\d{16}\b/, // Credit card (basic)
];
// High-cardinality value patterns
const HIGH_CARDINALITY_PATTERNS = [
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, // UUID
/^\d{13,}$/, // Timestamps
/^[A-Za-z0-9+/]{20,}={0,2}$/, // Base64
];
// ============================================================================
// Implementation
// ============================================================================
/**
* Create a safe baggage schema with validation and guardrails
*
* @param schema - Field definitions
* @param options - Safety options
* @returns Type-safe baggage schema
*
* @example
* ```typescript
* const MyBaggage = createSafeBaggageSchema({
* userId: { type: 'string', hash: true },
* region: { type: 'enum', values: ['us', 'eu', 'ap'] },
* debug: { type: 'boolean', defaultValue: false },
* });
* ```
*/
export function createSafeBaggageSchema<T extends BaggageSchemaDefinition>(
schema: T,
options: SafeBaggageOptions = {},
): SafeBaggageSchema<T> {
const {
maxKeyLength = DEFAULT_MAX_KEY_LENGTH,
maxValueLength = DEFAULT_MAX_VALUE_LENGTH,
maxTotalSize = DEFAULT_MAX_TOTAL_SIZE,
prefix = '',
hashHighCardinality = false,
redactPII = false,
allowedKeys,
onError,
} = options;
// Validate schema keys
const schemaKeys = new Set(Object.keys(schema));
if (allowedKeys) {
for (const key of schemaKeys) {
if (!allowedKeys.includes(key)) {
throw new Error(`Key "${key}" not in allowedKeys whitelist`);
}
}
}
// Prefix a key
const prefixKey = (key: string): string =>
prefix ? `${prefix}.${key}` : key;
// Hash a value using simple FNV-1a (synchronous, no crypto dependency)
const hashValue = (value: string): string => {
let hash = 2_166_136_261;
for (let i = 0; i < value.length; i++) {
hash ^= value.codePointAt(i) ?? 0;
hash = (hash * 16_777_619) >>> 0;
}
return `h_${hash.toString(16)}`;
};
// Check for PII
const containsPII = (value: string): boolean => {
return PII_PATTERNS.some((pattern) => pattern.test(value));
};
// Check for high-cardinality
const isHighCardinality = (value: string): boolean => {
return HIGH_CARDINALITY_PATTERNS.some((pattern) => pattern.test(value));
};
// Validate and transform a single value
const validateAndTransform = (
key: string,
value: unknown,
fieldDef: BaggageFieldDefinition,
): string | null => {
const fullKey = prefixKey(key);
// Check key length
if (fullKey.length > maxKeyLength) {
onError?.({
type: 'key_length',
key,
message: `Key "${key}" exceeds max length ${maxKeyLength}`,
});
return null;
}
// Handle undefined/null with default
if (value === undefined || value === null) {
if (fieldDef.required) {
onError?.({
type: 'validation',
key,
message: `Required field "${key}" is missing`,
});
return null;
}
if (fieldDef.defaultValue === undefined) {
return null;
} else {
value = fieldDef.defaultValue;
}
}
// Type validation
let stringValue: string;
switch (fieldDef.type) {
case 'string': {
if (typeof value !== 'string') {
onError?.({
type: 'validation',
key,
message: `Field "${key}" expected string, got ${typeof value}`,
value,
});
return null;
}
stringValue = value;
break;
}
case 'number': {
if (typeof value !== 'number' || Number.isNaN(value)) {
onError?.({
type: 'validation',
key,
message: `Field "${key}" expected number, got ${typeof value}`,
value,
});
return null;
}
stringValue = String(value);
break;
}
case 'boolean': {
if (typeof value !== 'boolean') {
onError?.({
type: 'validation',
key,
message: `Field "${key}" expected boolean, got ${typeof value}`,
value,
});
return null;
}
stringValue = String(value);
break;
}
case 'enum': {
if (!fieldDef.values?.includes(String(value))) {
onError?.({
type: 'validation',
key,
message: `Field "${key}" value "${value}" not in allowed values: ${fieldDef.values?.join(', ')}`,
value,
});
return null;
}
stringValue = String(value);
break;
}
default: {
stringValue = String(value);
}
}
// Custom validation
if (fieldDef.validate && !fieldDef.validate(value)) {
onError?.({
type: 'validation',
key,
message: `Field "${key}" failed custom validation`,
value,
});
return null;
}
// PII check
if (redactPII && containsPII(stringValue)) {
onError?.({
type: 'pii',
key,
message: `Field "${key}" contains PII pattern`,
value: '[REDACTED]',
});
stringValue = hashValue(stringValue);
}
// Hash if requested or high-cardinality
if (
fieldDef.hash ||
(hashHighCardinality && isHighCardinality(stringValue))
) {
stringValue = hashValue(stringValue);
}
// Length validation
const maxLen = fieldDef.maxLength ?? maxValueLength;
if (stringValue.length > maxLen) {
onError?.({
type: 'value_length',
key,
message: `Field "${key}" value exceeds max length ${maxLen}`,
value: stringValue,
});
stringValue = stringValue.slice(0, maxLen);
}
return stringValue;
};
// Parse value back from baggage string
const parseValue = (
key: string,
stringValue: string,
fieldDef: BaggageFieldDefinition,
): unknown => {
switch (fieldDef.type) {
case 'number': {
return Number.parseFloat(stringValue);
}
case 'boolean': {
return stringValue === 'true';
}
default: {
return stringValue;
}
}
};
return {
get(): Partial<InferBaggageType<T>> {
const baggage = propagation.getBaggage(context.active());
if (!baggage) {
return {};
}
const result: Record<string, unknown> = {};
for (const [key, fieldDef] of Object.entries(schema)) {
const fullKey = prefixKey(key);
const entry = baggage.getEntry(fullKey);
if (entry) {
result[key] = parseValue(key, entry.value, fieldDef);
} else if (fieldDef.defaultValue !== undefined) {
result[key] = fieldDef.defaultValue;
}
}
return result as Partial<InferBaggageType<T>>;
},
set(
ctx: TraceContext | undefined,
values: Partial<InferBaggageType<T>>,
): void {
let baggage =
propagation.getBaggage(context.active()) ?? propagation.createBaggage();
let totalSize = 0;
// Calculate existing size
for (const [key, entry] of baggage.getAllEntries()) {
totalSize += key.length + entry.value.length;
}
for (const [key, value] of Object.entries(values)) {
const fieldDef = schema[key];
if (!fieldDef) continue;
const fullKey = prefixKey(key);
const stringValue = validateAndTransform(key, value, fieldDef);
if (stringValue !== null) {
// Check total size
const entrySize = fullKey.length + stringValue.length;
if (totalSize + entrySize > maxTotalSize) {
onError?.({
type: 'size',
key,
message: `Adding "${key}" would exceed max baggage size ${maxTotalSize}`,
value,
});
continue;
}
baggage = baggage.setEntry(fullKey, { value: stringValue });
totalSize += entrySize;
}
}
// Update context with new baggage
const newContext = propagation.setBaggage(context.active(), baggage);
// Note: This only works if the caller propagates the context
// In OTel, baggage propagation happens via context.with()
// For now we set on active context
propagation.setBaggage(newContext, baggage);
},
getValue<K extends keyof T>(key: K): InferBaggageType<T>[K] | undefined {
const baggage = propagation.getBaggage(context.active());
if (!baggage) return undefined;
const fullKey = prefixKey(String(key));
const entry = baggage.getEntry(fullKey);
const fieldDef = schema[String(key)];
if (!entry) {
return fieldDef?.defaultValue as InferBaggageType<T>[K] | undefined;
}
if (!fieldDef) {
return undefined;
}
return parseValue(
String(key),
entry.value,
fieldDef,
) as InferBaggageType<T>[K];
},
setValue<K extends keyof T>(
key: K,
value: InferBaggageType<T>[K],
ctx?: TraceContext,
): void {
this.set(ctx, { [key]: value } as Partial<InferBaggageType<T>>);
},
clear(): void {
let baggage = propagation.getBaggage(context.active());
if (!baggage) return;
for (const key of Object.keys(schema)) {
const fullKey = prefixKey(key);
baggage = baggage.removeEntry(fullKey);
}
propagation.setBaggage(context.active(), baggage);
},
toHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers);
return headers;
},
fromHeaders(headers: Record<string, string>, ctx?: TraceContext): void {
const extractedContext = propagation.extract(context.active(), headers);
const baggage = propagation.getBaggage(extractedContext);
if (baggage) {
const values: Record<string, unknown> = {};
for (const [key, fieldDef] of Object.entries(schema)) {
const fullKey = prefixKey(key);
const entry = baggage.getEntry(fullKey);
if (entry) {
values[key] = parseValue(key, entry.value, fieldDef);
}
}
this.set(ctx, values as Partial<InferBaggageType<T>>);
}
},
};
}
// ============================================================================
// Pre-built Business Context Schema
// ============================================================================
/**
* Pre-built baggage schema for common business context fields
*
* Fields:
* - `tenantId`: Multi-tenant identifier (string, max 64 chars)
* - `userId`: User identifier (hashed for privacy)
* - `correlationId`: Request correlation ID (string)
* - `workflowId`: Workflow/saga instance ID (string)
* - `priority`: Request priority (low, normal, high, critical)
* - `region`: Geographic region (string)
* - `channel`: Request channel (web, mobile, api, internal)
*
* @example
* ```typescript
* import { BusinessBaggage } from 'autotel/business-baggage';
*
* // Set business context at entry point
* BusinessBaggage.set(ctx, {
* tenantId: 'acme-corp',
* userId: 'user-123',
* priority: 'high',
* channel: 'api',
* });
*
* // Access anywhere in the trace
* const { tenantId, priority } = BusinessBaggage.get(ctx);
* ```
*/
export const BusinessBaggage = createSafeBaggageSchema(
{
tenantId: {
type: 'string',
maxLength: 64,
},
userId: {
type: 'string',
hash: true, // Auto-hash for privacy
maxLength: 64,
},
correlationId: {
type: 'string',
maxLength: 128,
},
workflowId: {
type: 'string',
maxLength: 128,
},
priority: {
type: 'enum',
values: ['low', 'normal', 'high', 'critical'] as const,
defaultValue: 'normal',
},
region: {
type: 'string',
maxLength: 32,
},
channel: {
type: 'enum',
values: [
'web',
'mobile',
'api',
'internal',
'webhook',
'scheduled',
] as const,
},
},
{
prefix: 'biz',
redactPII: true,
hashHighCardinality: true,
},
);
/**
* Type alias for BusinessBaggage values
*/
export type BusinessBaggageValues = {
tenantId?: string;
userId?: string;
correlationId?: string;
workflowId?: string;
priority?: 'low' | 'normal' | 'high' | 'critical';
region?: string;
channel?: 'web' | 'mobile' | 'api' | 'internal' | 'webhook' | 'scheduled';
};