import { isUnsafeKey, MAX_ATTRIBUTE_KEYS, MAX_TAG_KEYS, MAX_TAG_VALUE_LEN } from './limits'; import type { JourneyEvent, JourneyStepEvent, TagValue } from './types'; import { MAX_CORRELATED_REQUESTS, MAX_JOURNEY_CORRELATED_REQUESTS, sanitizeRequestCorrelation, } from './request-correlation'; const SANITIZED_EVENT = Symbol.for('st.journey.sanitized'); /** Match Datadog metric-tag value rules so RUM attributes / globals join derived SLO tags. */ export function normalizeTagValue(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9_\-:./]/g, '_') .replace(/_+/g, '_') .slice(0, MAX_TAG_VALUE_LEN) .replace(/_+$/, ''); } export function emptyRecord(): Record { return Object.create(null) as Record; } function asTagValue(value: unknown): TagValue | undefined { if (typeof value === 'string') { return normalizeTagValue(value); } if (typeof value === 'number' || typeof value === 'boolean') { return value; } return undefined; } export interface CopyRecordOptions { maxKeys: number; /** When true, keys are run through {@link normalizeTagValue} (sink / wire). */ normalizeKeys?: boolean; /** When true (default), string values are normalized. */ normalizeStringValues?: boolean; } /** * Own-key copy onto a null-prototype object. Skips unsafe keys and excess keys. */ export function copyRecord( src: Record | undefined, options: CopyRecordOptions ): Record { const out = emptyRecord(); if (src == null || typeof src !== 'object') { return out; } const normalizeKeys = options.normalizeKeys === true; const normalizeStringValues = options.normalizeStringValues !== false; for (const key of Object.keys(src)) { if (isUnsafeKey(key)) { continue; } const destKey = normalizeKeys ? normalizeTagValue(key) : key; if (!destKey || isUnsafeKey(destKey)) { continue; } const raw = src[key]; const value = normalizeStringValues ? asTagValue(raw) : typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean' ? raw : undefined; if (value === undefined) { continue; } if (Object.prototype.hasOwnProperty.call(out, destKey)) { out[destKey] = value; continue; } if (Object.keys(out).length >= options.maxKeys) { continue; } out[destKey] = value; } return out; } /** Merge `src` into `target` (null-prototype), honouring caps and unsafe-key skips. */ export function assignRecord( target: Record, src: Record | undefined, maxKeys: number ): void { if (src == null || typeof src !== 'object') { return; } let room = maxKeys - Object.keys(target).length; for (const key of Object.keys(src)) { if (isUnsafeKey(key)) { continue; } const raw = src[key]; if (typeof raw !== 'string' && typeof raw !== 'number' && typeof raw !== 'boolean') { continue; } const exists = Object.prototype.hasOwnProperty.call(target, key); if (!exists && room <= 0) { continue; } if (!exists) { room -= 1; } target[key] = raw; } } export function setSafeAttribute( attributes: Record, key: string, value: string | number | boolean ): void { if (isUnsafeKey(key)) { return; } const exists = Object.prototype.hasOwnProperty.call(attributes, key); if (!exists && Object.keys(attributes).length >= MAX_ATTRIBUTE_KEYS) { return; } const next = asTagValue(value); if (next !== undefined) { attributes[key] = next; } } function sanitizeStep(step: JourneyStepEvent): JourneyStepEvent { const { requests: rawRequests, requestsTruncated, ...rest } = step; const requests = Array.isArray(rawRequests) ? rawRequests .slice(0, MAX_CORRELATED_REQUESTS) .map(sanitizeRequestCorrelation) .filter(request => request !== undefined) : []; const name = normalizeTagValue(step.name); return { ...rest, ...(requests.length ? { requests } : {}), ...(requestsTruncated === true ? { requestsTruncated: true } : {}), name: name || step.name, ...(step.reason ? { reason: normalizeTagValue(step.reason) } : {}), ...(step.attributes ? { attributes: copyRecord(step.attributes, { maxKeys: MAX_ATTRIBUTE_KEYS, normalizeKeys: true, }), } : {}), }; } /** Default-sink payload: normalized keys/names, capped maps, no unsafe keys. */ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent { const j = event.journey; const expected = j.expected?.map(name => normalizeTagValue(name)).filter(name => name !== ''); const tags = j.tags ? copyRecord(j.tags, { maxKeys: MAX_TAG_KEYS, normalizeKeys: true }) : undefined; const tagKeys = tags ? Object.keys(tags) : []; const steps = j.steps.map(sanitizeStep); // Prefer recently started steps at the cap; failedRequest preserves the scoring request separately. let requestBudget = MAX_JOURNEY_CORRELATED_REQUESTS; for (let index = steps.length - 1; index >= 0; index--) { const step = steps[index]; if (!step.requests) { continue; } if (step.requests.length > requestBudget) { step.requestsTruncated = true; if (requestBudget === 0) { delete step.requests; } else { step.requests = step.requests.slice(-requestBudget); } } requestBudget -= step.requests?.length ?? 0; } const failedRequest = j.outcome === 'bad' && j.failedRequest ? sanitizeRequestCorrelation(j.failedRequest) : undefined; const sanitized: JourneyEvent = { journey: { name: normalizeTagValue(j.name), team: normalizeTagValue(j.team), group: normalizeTagValue(j.group), service: normalizeTagValue(j.service), outcome: j.outcome, ...(failedRequest ? { failedRequest } : {}), ...(j.reason ? { reason: normalizeTagValue(j.reason) } : {}), durationMs: j.durationMs, steps, ...(expected?.length ? { expected } : {}), ...(tagKeys.length ? { tags } : {}), }, }; Object.defineProperty(sanitized, SANITIZED_EVENT, { value: true, enumerable: false, configurable: false, }); return sanitized; } /** True when payload already passed through sanitizeJourneyEvent. */ export function isSanitizedJourneyEvent(event: JourneyEvent): boolean { return (event as unknown as Record)[SANITIZED_EVENT] === true; }