autotel
Version:
Write Once, Observe Anywhere
198 lines (196 loc) • 7.51 kB
JavaScript
import { trace } from "@opentelemetry/api";
import { logs } from "@opentelemetry/api-logs";
//#region src/feature-flags.ts
/**
* Feature flag evaluations, in the vocabulary OpenTelemetry already defined.
*
* A flagged rollout splits your traffic into two populations that share a
* service name, a route and a version. Without the flag on the span there is no
* way to ask the only question that matters during a rollout — is the new
* branch slower, or failing more, than the old one — and the usual workaround
* is to read it in the flag vendor's own dashboard, where the latency and the
* errors are not.
*
* The specification covers this: `feature_flag.key`, `.result.value`,
* `.result.variant`, `.result.reason`, `.provider.name`, `.context.id`, plus a
* `feature_flag.evaluation` event. Emitting those means any backend can split
* any metric by variant, with no vendor in the path.
*
* ## Attributes and events both
*
* Span attributes hold one flag: a second call overwrites the first. A request
* that branched on three flags needs one event each, which is what the
* `feature_flag.evaluation` event is for. Both are recorded — attributes so a
* single-flag span is filterable without unpacking events, events so a
* many-flag span keeps them all.
*
* ## Record what you branched on
*
* Record a flag where the code *reads* it, not where it is fetched. A flag
* evaluated and ignored explains nothing; the value the request actually took a
* branch on is the one that explains its behaviour.
*/
/** Canonical `feature_flag.*` attribute keys. */
const FEATURE_FLAG = {
KEY: "feature_flag.key",
RESULT_VALUE: "feature_flag.result.value",
RESULT_VARIANT: "feature_flag.result.variant",
RESULT_REASON: "feature_flag.result.reason",
PROVIDER_NAME: "feature_flag.provider.name",
CONTEXT_ID: "feature_flag.context.id",
SET_ID: "feature_flag.set.id",
VERSION: "feature_flag.version",
/**
* Why an evaluation failed. `feature_flag.evaluation.error.message` is the
* deprecated spelling of this and is deliberately not emitted — writing both
* would double the cardinality to no benefit.
*/
ERROR_MESSAGE: "feature_flag.error.message"
};
/** Canonical event name for one evaluation. */
const FEATURE_FLAG_EVALUATION_EVENT = "feature_flag.evaluation";
/**
* Canonical `feature_flag.result.reason` values. The registry defines them in
* lower snake case; OpenFeature and most SDKs report them upper-cased, and
* forwarding that splits every group-by into two buckets meaning the same
* thing.
*/
const FEATURE_FLAG_REASON = [
"static",
"default",
"targeting_match",
"split",
"cached",
"disabled",
"unknown",
"stale",
"error"
];
/**
* A flag value as an attribute.
*
* Booleans and numbers are kept as they are: `feature_flag.result.value`
* permits typed values, and stringifying `true` makes a boolean flag
* uncomparable with the numeric one next to it. Only a structured value — which
* no attribute type can hold — is serialised.
*/
function asAttributeValue(value) {
if (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return value;
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
/** Canonical attributes for one evaluation. Absent fields are omitted. */
function featureFlagAttributes(evaluation) {
const attributes = {
[FEATURE_FLAG.KEY]: evaluation.key,
[FEATURE_FLAG.RESULT_VALUE]: asAttributeValue(evaluation.value)
};
const optional = [
[FEATURE_FLAG.RESULT_VARIANT, evaluation.variant],
[FEATURE_FLAG.RESULT_REASON, evaluation.reason === void 0 ? void 0 : evaluation.reason.toLowerCase()],
[FEATURE_FLAG.PROVIDER_NAME, evaluation.provider],
[FEATURE_FLAG.CONTEXT_ID, evaluation.contextId],
[FEATURE_FLAG.SET_ID, evaluation.setId],
[FEATURE_FLAG.VERSION, evaluation.version],
[FEATURE_FLAG.ERROR_MESSAGE, evaluation.errorMessage]
];
for (const [key, value] of optional) if (value !== void 0) attributes[key] = value;
return attributes;
}
/**
* Record a flag evaluation on `sink` — as attributes and as a
* `feature_flag.evaluation` event.
*
* A missing sink is a no-op: instrumentation must never be the reason a branch
* throws, and a flag read outside any span is a legitimate thing to do.
*/
function recordFeatureFlag(sink, evaluation) {
if (!sink) return;
const attributes = featureFlagAttributes(evaluation);
sink.setAttributes(attributes);
sink.track?.(FEATURE_FLAG_EVALUATION_EVENT, attributes);
}
/**
* Emit the evaluation as an OpenTelemetry **log record**.
*
* This is the Logs API model the repository emits events through. The obvious
* shortcut — `Span.addEvent` on the active span — puts the evaluation somewhere
* no log or event pipeline will ever look, which is exactly the failure the
* invariant exists to prevent. `correlated-events` is not a way around it
* either: that helper prefers `addEvent` when the target has one, so routing
* through it lands in the same place with more indirection.
*
* `logs.getLogger` returns a no-op logger when no `LoggerProvider` is
* registered, so this costs nothing in an application that exports no logs.
*/
function emitEvaluationRecord(attributes) {
logs.getLogger("autotel").emit({
eventName: FEATURE_FLAG_EVALUATION_EVENT,
attributes
});
}
function evaluationFrom(hookContext, value, extra) {
return {
key: hookContext.flagKey,
value,
provider: hookContext.providerMetadata?.name,
contextId: hookContext.context?.targetingKey,
setId: hookContext.clientMetadata?.name,
...extra
};
}
/**
* An OpenFeature hook that records every evaluation under the canonical
* `feature_flag.*` convention.
*
* This is the zero-config path: OpenFeature already sits between the
* application and whichever flag vendor it uses, and it already fires on every
* evaluation — which is exactly the moment worth recording, because it is the
* moment the code branched.
*
* ```ts
* import { OpenFeature } from '@openfeature/server-sdk';
* import { autotelOpenFeatureHook } from 'autotel/feature-flags';
*
* OpenFeature.addHooks(autotelOpenFeatureHook());
* ```
*
* A failed evaluation is still recorded, with the default value the code
* actually used and `reason: 'ERROR'`. The default is what the request behaved
* as; the failure is why.
*/
function autotelOpenFeatureHook(options = {}) {
const getSpan = options.getSpan ?? (() => trace.getActiveSpan());
const emitLogRecord = options.emitLogRecord ?? emitEvaluationRecord;
/**
* Attributes onto the span that branched, and the event wherever the sink
* says. A sink with no `track` of its own gets a log record — including when
* there is no span at all, because the evaluation still happened.
*/
const record = (evaluation) => {
const sink = getSpan();
const attributes = featureFlagAttributes(evaluation);
sink?.setAttributes(attributes);
if (sink?.track) sink.track(FEATURE_FLAG_EVALUATION_EVENT, attributes);
else emitLogRecord(attributes);
};
return {
after(hookContext, details) {
record(evaluationFrom(hookContext, details.value, {
variant: details.variant,
reason: details.reason
}));
},
error(hookContext, error) {
record(evaluationFrom(hookContext, hookContext.defaultValue, {
reason: "error",
errorMessage: error instanceof Error ? error.message : String(error)
}));
}
};
}
//#endregion
export { FEATURE_FLAG, FEATURE_FLAG_EVALUATION_EVENT, FEATURE_FLAG_REASON, autotelOpenFeatureHook, featureFlagAttributes, recordFeatureFlag };