UNPKG

autotel

Version:
399 lines (397 loc) 14.6 kB
import * as nodeFs from "node:fs"; import path from "node:path"; //#region src/policy.ts /** * Telemetry Policies (experimental) * * Implements the applier side of OTEP 4738 (`oteps/4738-telemetry-policy.md`): * small, independent, fail-open rules that decide what telemetry is kept and * how it is transformed. Policies are portable — the same JSON runs here, in a * Collector, or in any other conforming implementation. * * This is an *applier*, not an engine: policies compile down to the hooks * autotel already has (`spanFilter`, log record processors). No new pipeline. * * Supported stages: * - `trace.keep.percentage` — deterministic per-trace probabilistic sampling * - `log.keep` — `"all"` / `"none"` / a percentage * - `log.transform` — `remove` → `redact` → `rename` → `add` (spec order) * * Unsupported (policy is skipped, telemetry is not — per spec fail-open): * - `metric` targets * - `trace.keep.mode` / `sampling_precision` / `hash_seed` / `fail_closed` * (OTEP 235 consistent-probability sampling; autotel's samplers are not * consistent-probability yet) * - `event_attribute` / `link_trace_id` matchers * * @example * ```typescript * init({ service: 'api', policies: './policies' }) * ``` * * ```json * { * "id": "drop-debug-logs", * "log": { "match": [{ "log_field": "severity_text", "regex": "^(DEBUG|TRACE)$" }], "keep": "none" } * } * ``` */ /** * Values longer than this are not regex-matched (the matcher yields no match). * * The spec mandates RE2 for cross-implementation consistency; Node's `RegExp` * backtracks, so an untrusted log body plus a pathological pattern is a DoS. * Capping input bounds the damage without pulling in an RE2 binding. * * ponytail: length cap instead of a real RE2 engine — swap in a linear-time * matcher if policies ever run against fully untrusted patterns. */ const MAX_MATCH_LENGTH = 4096; let activePolicies = []; const warned = /* @__PURE__ */ new Set(); function warnOnce(key, message) { if (warned.has(key)) return; warned.add(key); console.warn(`[autotel] ${message}`); } const MATCH_OPERATORS = [ "exact", "regex", "exists", "starts_with", "ends_with", "contains" ]; const FIELD_SELECTORS = [ "log_field", "trace_field", "span_attribute", "log_attribute", "resource_attribute", "scope_attribute", "span_kind", "span_status" ]; function countSet(source, keys) { return keys.filter((key) => source[key] !== void 0).length; } /** * Returns a reason string if the policy cannot be applied, or undefined if it can. * * Per spec an implementation MAY support a subset of stages but MUST skip the * policy — never the telemetry — when it meets one it does not understand. */ function unsupportedReason(policy) { if (!policy || typeof policy.id !== "string" || policy.id === "") return "policy requires an id"; if (policy.metric !== void 0) return "metric targets are not supported"; if (countSet(policy, ["trace", "log"]) !== 1) return "exactly one target (trace or log) must be set"; const target = policy.trace ?? policy.log; if (!Array.isArray(target?.match) || target.match.length === 0) return "at least one matcher is required"; for (const matcher of target.match) { if (countSet(matcher, FIELD_SELECTORS) !== 1) return "each matcher must set exactly one field selector"; if (countSet(matcher, MATCH_OPERATORS) !== 1) return "each matcher must set exactly one match operator"; } if (policy.trace?.keep) { const { percentage, mode, sampling_precision, hash_seed, fail_closed } = policy.trace.keep; if (mode !== void 0 || sampling_precision !== void 0 || hash_seed !== void 0 || fail_closed !== void 0) return "trace keep supports \"percentage\" only (consistent-probability sampling modes are not implemented)"; if (percentage !== void 0 && (typeof percentage !== "number" || Number.isNaN(percentage) || percentage < 0 || percentage > 100)) return "trace keep percentage must be between 0 and 100"; } } /** * Replace the active policy set. * * Disabled policies are dropped (spec: they MUST be treated as if they do not * exist) and unsupported ones are skipped with a warning. */ function setPolicies(policies) { const accepted = []; for (const policy of policies ?? []) { if (policy?.enabled === false) continue; const reason = unsupportedReason(policy); if (reason) { warnOnce(`skip:${policy?.id ?? "unknown"}:${reason}`, `policy "${policy?.id ?? "unknown"}" skipped: ${reason}`); continue; } accepted.push(policy); } activePolicies = accepted; } /** The currently active (validated, enabled) policies. */ function getPolicies() { return activePolicies; } function clearPolicies() { activePolicies = []; warned.clear(); } const regexCache = /* @__PURE__ */ new Map(); function compileRegex(pattern, caseInsensitive) { const key = `${caseInsensitive ? "i" : ""}${pattern}`; const cached = regexCache.get(key); if (cached !== void 0) return cached; let compiled = null; try { compiled = new RegExp(pattern, caseInsensitive ? "i" : ""); } catch { warnOnce(`regex:${key}`, `policy regex failed to compile: ${pattern}`); } regexCache.set(key, compiled); return compiled; } function joinPath(attributePath) { return Array.isArray(attributePath) ? attributePath.join(".") : attributePath; } function toText(value) { if (value === void 0 || value === null) return void 0; if (Array.isArray(value)) return value.map((item) => String(item)).join(","); if (typeof value === "object") return void 0; return String(value); } function matchesOperator(matcher, raw) { if (matcher.exists !== void 0) return (raw !== void 0 && raw !== null) === matcher.exists; const text = toText(raw); if (text === void 0) return false; const insensitive = matcher.case_insensitive === true; const subject = insensitive ? text.toLowerCase() : text; const fold = (value) => insensitive ? value.toLowerCase() : value; if (matcher.exact !== void 0) return subject === fold(matcher.exact); if (matcher.starts_with !== void 0) return subject.startsWith(fold(matcher.starts_with)); if (matcher.ends_with !== void 0) return subject.endsWith(fold(matcher.ends_with)); if (matcher.contains !== void 0) return subject.includes(fold(matcher.contains)); if (matcher.regex !== void 0) { if (text.length > 4096) return false; const compiled = compileRegex(matcher.regex, insensitive); return compiled ? compiled.test(text) : false; } return false; } function resolveField(matcher, view) { if (matcher.log_field !== void 0) return view.field(matcher.log_field); if (matcher.trace_field !== void 0) return view.field(matcher.trace_field); if (matcher.span_kind !== void 0) return view.field("kind"); if (matcher.span_status !== void 0) return view.field("status_code"); if (matcher.span_attribute !== void 0) return view.attribute(joinPath(matcher.span_attribute)); if (matcher.log_attribute !== void 0) return view.attribute(joinPath(matcher.log_attribute)); if (matcher.resource_attribute !== void 0) return view.resource(joinPath(matcher.resource_attribute)); if (matcher.scope_attribute !== void 0) return view.scope(joinPath(matcher.scope_attribute)); } /** All matchers are ANDed (spec). */ function matchesAll(matchers, view) { return matchers.every((matcher) => { const result = matchesOperator(matcher, resolveField(matcher, view)); return matcher.negate === true ? !result : result; }); } /** FNV-1a → [0, 1). Deterministic so every span in a trace decides alike. */ function hashUnitInterval(value) { let hash = 2166136261; for (let index = 0; index < value.length; index++) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0) % 1e4 / 1e4; } function parseKeep(keep) { if (keep === void 0 || keep === "all") return 100; if (keep === "none") return 0; const percentage = typeof keep === "number" ? keep : Number.parseFloat(keep); if (Number.isNaN(percentage)) return 100; return Math.min(100, Math.max(0, percentage)); } /** * Every matching policy contributes a keep value and the most restrictive wins * (spec). Returns 100 when nothing matched. */ function mostRestrictiveKeep(percentages) { return percentages.length === 0 ? 100 : Math.min(...percentages); } function sampled(percentage, seed) { if (percentage >= 100) return true; if (percentage <= 0) return false; return (seed ? hashUnitInterval(seed) : Math.random()) < percentage / 100; } function spanView(span) { return { field(name) { switch (name) { case "name": return span.name; case "trace_id": return span.spanContext().traceId; case "span_id": return span.spanContext().spanId; case "kind": return span.kind; case "status_code": return span.status.code; case "status_message": return span.status.message; default: return; } }, attribute: (key) => span.attributes[key], resource: (key) => span.resource.attributes[key], scope: (key) => key === "name" ? span.instrumentationScope.name : void 0 }; } /** * A `SpanFilterPredicate` backed by the active policies. Returns true to keep. * * Fail-open: any error keeps the span. */ function policySpanFilter(span) { try { const view = spanView(span); const keeps = []; for (const policy of activePolicies) { if (!policy.trace) continue; if (!matchesAll(policy.trace.match, view)) continue; keeps.push(policy.trace.keep?.percentage ?? 100); } return sampled(mostRestrictiveKeep(keeps), span.spanContext().traceId); } catch (error) { warnOnce("trace-eval", `policy evaluation failed for a span, keeping it: ${String(error)}`); return true; } } function logView(record) { return { field(name) { switch (name) { case "body": return record.body; case "severity_text": return record.severityText; case "severity_number": return record.severityNumber; case "event_name": return record.eventName; case "trace_id": return record.spanContext?.traceId; case "span_id": return record.spanContext?.spanId; default: return; } }, attribute: (key) => record.attributes?.[key], resource: (key) => record.resource?.attributes?.[key], scope: (key) => key === "name" ? record.instrumentationScope?.name : void 0 }; } function applyField(record, field, apply, applyBody) { if (field.log_field === "body") { applyBody(); return; } if (field.log_attribute !== void 0) apply(record.attributes, joinPath(field.log_attribute)); } function applyTransform(record, transform) { for (const field of transform.remove ?? []) applyField(record, field, (attributes, key) => { delete attributes[key]; }, () => { record.body = void 0; }); for (const field of transform.redact ?? []) { const replacement = field.replacement ?? "[REDACTED]"; applyField(record, field, (attributes, key) => { if (key in attributes) attributes[key] = replacement; }, () => { record.body = replacement; }); } for (const field of transform.rename ?? []) applyField(record, field, (attributes, key) => { if (!(key in attributes)) return; if (key in attributes && field.to in attributes && !field.upsert) return; attributes[field.to] = attributes[key]; delete attributes[key]; }, () => {}); for (const field of transform.add ?? []) applyField(record, field, (attributes, key) => { if (key in attributes && field.upsert !== true) return; attributes[key] = field.value; }, () => { if (record.body === void 0 || field.upsert === true) record.body = field.value; }); } /** * Applies `log` policies — keep first, then transform (spec stage order). * * Fail-open: any error emits the record unmodified. */ var PolicyLogRecordProcessor = class { wrapped; constructor(wrapped) { this.wrapped = wrapped; } onEmit(logRecord, context) { try { const view = logView(logRecord); const keeps = []; const transforms = []; for (const policy of activePolicies) { if (!policy.log) continue; if (!matchesAll(policy.log.match, view)) continue; keeps.push(parseKeep(policy.log.keep)); if (policy.log.transform) transforms.push(policy.log.transform); } if (!sampled(mostRestrictiveKeep(keeps), logRecord.spanContext?.traceId)) return; for (const transform of transforms) applyTransform(logRecord, transform); } catch (error) { warnOnce("log-eval", `policy evaluation failed for a log record, keeping it: ${String(error)}`); } this.wrapped.onEmit(logRecord, context); } shutdown() { return this.wrapped.shutdown(); } forceFlush() { return this.wrapped.forceFlush(); } }; /** True when any active policy targets logs — used to skip wrapping otherwise. */ function hasLogPolicies() { return activePolicies.some((policy) => policy.log !== void 0); } function readPolicyFile(filePath) { const parsed = JSON.parse(nodeFs.readFileSync(filePath, "utf8")); return Array.isArray(parsed) ? parsed : [parsed]; } /** * Load every policy from a `.json` file, or from every `.json` file in a * directory. * * Fail-open: an unreadable or malformed file yields no policies from that file * and warns; it never throws. */ function loadPolicies(target) { const resolved = path.resolve(target); let files; try { files = nodeFs.statSync(resolved).isDirectory() ? nodeFs.readdirSync(resolved).filter((entry) => entry.endsWith(".json")).map((entry) => path.join(resolved, entry)) : [resolved]; } catch (error) { warnOnce(`read:${resolved}`, `could not read policies from ${resolved}: ${String(error)}`); return []; } const policies = []; for (const file of files) try { policies.push(...readPolicyFile(file)); } catch (error) { warnOnce(`parse:${file}`, `could not parse policy file ${file}: ${String(error)}`); } return policies; } /** * The file policy provider: load `target` now and reload on change. * * Policies are expected to change outside the lifecycle of the process, so the * watcher is what makes them dynamic. The watcher is unref'd — it never holds * the process open. * * @returns a function that stops watching */ function watchPolicyFile(target) { const resolved = path.resolve(target); const reload = () => { setPolicies(loadPolicies(resolved)); }; reload(); try { const watcher = nodeFs.watch(resolved, { persistent: false }, reload); watcher.on("error", () => {}); return () => { watcher.close(); }; } catch (error) { warnOnce(`watch:${resolved}`, `could not watch ${resolved} for policy changes: ${String(error)}`); return () => {}; } } //#endregion export { MAX_MATCH_LENGTH, PolicyLogRecordProcessor, clearPolicies, getPolicies, hasLogPolicies, loadPolicies, policySpanFilter, setPolicies, unsupportedReason, watchPolicyFile };