@zendesk/retrace
Version:
define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API
740 lines (689 loc) • 24.3 kB
text/typescript
import type { SpanAnnotation } from './spanAnnotationTypes'
import type { Attributes, Span, SpanStatus, SpanType } from './spanTypes'
import type { RecordedSpan } from './traceRecordingTypes'
import type {
DraftTraceContext,
MapSchemaToTypes,
RelationSchemasBase,
TraceContext,
} from './types'
import type { UnionToIntersection } from './typeUtils'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const INACTIVE_CONTEXT: DraftTraceContext<any, any, any> = {
definition: {
computedSpanDefinitions: {},
computedValueDefinitions: {},
name: 'NO_TRACE_ACTIVE',
relationSchema: {},
relationSchemaName: 'NONE',
requiredSpans: [],
variants: { none: { timeout: 0 } },
},
input: {
id: 'NO_TRACE_ACTIVE',
relatedTo: {},
startTime: { now: 0, epoch: 0 },
variant: 'none',
},
recordedItems: new Map(),
recordedItemsByLabel: {},
}
export interface PublicSpanMatcherTags {
/**
* Only applicable for 'requiredSpans' list: it will opt-out of the default behavior,
* which interrupts the trace if the requiredSpan has an error status.
*/
continueWithErrorStatus?: boolean
/**
* If multiple matches are found, this specifies which match to use.
* It can be set to a negative number to match from the end of the operation (backwards, with syntax like Array.prototype.slice()).
* This only has an effect on matchers that run when the recording is complete,
* e.g. in startSpan and endSpan for defining computed spans.
*/
nthMatch?: number
/**
* Do not consider entries before this index.
* This only has an effect on matchers that run when the recording is complete.
*/
lowestIndexToConsider?: number
/**
* Index of last entry to consider. Will stop considering entries beyond this index.
* This only has an effect on matchers that run when the recording is complete.
*/
highestIndexToConsider?: number
}
export interface SpanMatcherTags extends PublicSpanMatcherTags {
/**
* @internal
* Enables the idle-regression check.
* Only has an effect in for component-lifecycle entries in 'requiredSpans' matchers list.
*/
idleCheck?: boolean
/**
* @internal
*/
requiredSpan?: boolean
}
export interface SpanAndAnnotationForMatching<
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
> {
span: Span<RelationSchemasT> | RecordedSpan<RelationSchemasT>
annotation?: SpanAnnotation
}
/**
* Function type for matching performance entries.
*/
export interface SpanMatcherFn<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
VariantsT extends string,
> extends SpanMatcherTags {
(
spanAndAnnotation: SpanAndAnnotationForMatching<RelationSchemasT>,
context:
| DraftTraceContext<SelectedRelationNameT, RelationSchemasT, VariantsT>
| undefined,
): boolean
/** source definition object for debugging (if converted from object) */
fromDefinition?: SpanMatchDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>
}
export type NameMatcher<RelationSchemaT> =
| string
| RegExp
| ((
name: string,
inputRelation: MapSchemaToTypes<RelationSchemaT> | undefined,
) => boolean)
export interface SpanMatchDefinition<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
VariantsT extends string,
> extends PublicSpanMatcherTags {
name?: NameMatcher<RelationSchemasT[SelectedRelationNameT]>
performanceEntryName?: NameMatcher<RelationSchemasT[SelectedRelationNameT]>
type?: SpanType
status?: SpanStatus
attributes?: Attributes
id?: string
matchingRelations?:
| (keyof UnionToIntersection<RelationSchemasT[SelectedRelationNameT]>)[]
| boolean
/** The index of the reoccurrence within the span, calculated based on the span's type+name combination */
occurrence?: number | ((occurrence: number) => boolean)
isIdle?: boolean
label?: string
renderCount?: number
fn?: SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT>
oneOf?: SpanMatchDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>[]
not?: SpanMatchDefinition<SelectedRelationNameT, RelationSchemasT, VariantsT>
}
export type SpanMatch<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
VariantsT extends string,
> =
| SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT>
| SpanMatchDefinition<SelectedRelationNameT, RelationSchemasT, VariantsT>
export interface ParentSpanMatcher<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
VariantsT extends string,
> {
/**
* Define the scope of the search for the parent span.
* 'span-created-tick' will only search for spans created in the current tick,
* while 'entire-recording' will search through all recorded spans
* in the trace that was running when the span was created.
*
* Note that search === 'entire-recording' only works
* when running the matcher when traceContext is available
* (i.e. before Trace is disposed - while it's active, and when first creating the recording)
*/
search: 'span-created-tick' | 'span-ended-tick' | 'entire-recording'
searchDirection: 'after-self' | 'before-self'
match: SpanMatch<SelectedRelationNameT, RelationSchemasT, VariantsT>
}
/**
* The common name of the span to match. Can be a string, RegExp, or function.
*/
export function withName<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
value: NameMatcher<RelationSchemasT[SelectedRelationNameT]>,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ span }, { input: { relatedTo } } = INACTIVE_CONTEXT) => {
if (typeof value === 'string') return span.name === value
if (value instanceof RegExp) return value.test(span.name)
return value(span.name, relatedTo)
}
matcher.fromDefinition = { name: value }
return matcher
}
// DRAFT TODO: make test case if one doesnt exist yet
// withName((name, relatedTo) => !relatedTo ? false : name === `OmniLog/${relatedTo.ticketId}`)
export function withLabel<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
value: string,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ annotation }) => annotation?.labels?.includes(value) ?? false
matcher.fromDefinition = { label: value }
return matcher
}
/**
* The PerformanceEntry.name of the entry to match. Can be a string, RegExp, or function.
*/
export function withPerformanceEntryName<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
value: NameMatcher<RelationSchemasT[SelectedRelationNameT]>,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ span }, { input: { relatedTo } } = INACTIVE_CONTEXT) => {
const entryName = span.performanceEntry?.name
if (!entryName) return false
if (typeof value === 'string') return entryName === value
if (value instanceof RegExp) return value.test(entryName)
return value(entryName, relatedTo)
}
matcher.fromDefinition = { performanceEntryName: value }
return matcher
}
export function withType<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
value: SpanType,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ span }) => span.type === value
matcher.fromDefinition = { type: value }
return matcher
}
export function withStatus<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
value: SpanStatus,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ span }) => span.status === value
matcher.fromDefinition = { status: value }
return matcher
}
/**
* The subset of attributes (metadata) to match against the span.
*/
export function withAttributes<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
attrs: Attributes,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ span }) => {
if (!span.attributes) return false
return Object.entries(attrs).every(
([key, value]) => span.attributes[key] === value,
)
}
matcher.fromDefinition = { attributes: attrs }
return matcher
}
export function withId<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
value: string,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ span }) => span.id === value
matcher.fromDefinition = { id: value }
return matcher
}
/**
* A list of keys of trace's relations to match against the span's.
*/
export function withMatchingRelations<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
keys:
| NoInfer<
keyof UnionToIntersection<RelationSchemasT[SelectedRelationNameT]>
>[]
| true = true,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = (
{ span },
{
input: { relatedTo: r },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
definition: { relationSchema },
} = INACTIVE_CONTEXT,
) => {
// DRAFT TODO: add test case when relatedTo is missing
// if the relatedTo isn't set on the trace yet, we can't match against it, so we return early
// similarly, if the span doesn't have any relatedTo set
const relatedToInput: Record<string, unknown> | undefined = r
if (!span.relatedTo || !relatedToInput) return false
const spanRelatedTo: Record<string, unknown> = span.relatedTo
const resolvedKeys =
typeof keys === 'boolean' && keys
? Object.keys(relationSchema as object)
: (keys as string[])
if (!resolvedKeys) return false
return resolvedKeys.every(
(key) =>
key in spanRelatedTo && spanRelatedTo[key] === relatedToInput[key],
)
}
matcher.fromDefinition = { matchingRelations: keys }
return matcher
}
/**
* The occurrence of the span with the same name and type within the operation.
*/
export function withOccurrence<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
value: number | ((occurrence: number) => boolean),
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ annotation }) => {
if (!annotation) return false
if (typeof value === 'number') return annotation.occurrence === value
return value(annotation.occurrence)
}
matcher.fromDefinition = { occurrence: value }
return matcher
}
export function withComponentRenderCount<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
name: NameMatcher<RelationSchemasT[SelectedRelationNameT]>,
renderCount: number,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const nameMatcher = withName<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>(name)
const matcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = (spanAndAnnotation, context) => {
if (!('renderCount' in spanAndAnnotation.span)) return false
return (
nameMatcher(spanAndAnnotation, context) &&
spanAndAnnotation.span.renderCount === renderCount
)
}
matcher.fromDefinition = { name, renderCount }
return matcher
}
/**
* only applicable for component-lifecycle entries
*/
export function whenIdle<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
value = true,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcherFn: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ span }) => ('isIdle' in span ? span.isIdle === value : false)
const result = Object.assign(
matcherFn,
// add a tag to the function if set to true
value ? ({ idleCheck: value } satisfies SpanMatcherTags) : {},
)
result.fromDefinition = { isIdle: value }
return result
}
/**
* @internal
* tag matcher with a special, internal matcher tag, and match on span.status === 'error'
*/
export function requiredSpanWithErrorStatus<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcherFn: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = ({ span }) => span.status === 'error'
const result = Object.assign(
matcherFn,
// add a tag to the function if set to true
{ requiredSpan: true } satisfies SpanMatcherTags,
)
return result
}
/**
* Only applicable for 'requiredSpans' list: it will opt-out of the default behavior,
* which interrupts the trace if the requiredSpan has an error status.
*/
export function continueWithErrorStatus<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matcherFn: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = () => true
const result = Object.assign(
matcherFn,
// add a tag to the function if set to true
{ continueWithErrorStatus: true } satisfies SpanMatcherTags,
)
return result
}
// logical combinators:
// AND
export function withAllConditions<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
...matchers: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>[]
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const tags: SpanMatcherTags = {}
const definition: SpanMatchDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = {}
for (const matcher of matchers) {
// carry over tags from sub-matchers
Object.assign(tags, matcher)
if (matcher.fromDefinition) {
// carry over definition from sub-matchers
Object.assign(definition, matcher.fromDefinition)
}
}
const matcherFn: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = (...args) => matchers.every((matcher) => matcher(...args))
return Object.assign(matcherFn, tags, { fromDefinition: definition })
}
// OR
export function withOneOfConditions<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
...matchers: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>[]
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const tags: SpanMatcherTags = {}
for (const matcher of matchers) {
// carry over tags from sub-matchers
Object.assign(tags, matcher)
}
const matcherFn: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = (...args) => matchers.some((matcher) => matcher(...args))
return Object.assign(matcherFn, tags, { fromDefinition: { oneOf: matchers } })
}
export function not<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
matcher: SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT>,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
// Create a new matcher function that negates the input matcher
const notMatcher: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> = (...args) => !matcher(...args)
// If the original matcher has a fromDefinition property, create a new one for the negated matcher
if (matcher.fromDefinition) {
notMatcher.fromDefinition = { not: matcher.fromDefinition }
}
return notMatcher
}
export function fromDefinition<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
>(
definition: SpanMatchDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
): SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> {
const matchers: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>[] = []
// Handle special case: if both name and renderCount are present, use withComponentRenderCount
// instead of separate withName and other matchers
if (definition.renderCount !== undefined && definition.name) {
matchers.push(
withComponentRenderCount(definition.name, definition.renderCount),
)
} else if (definition.name) {
matchers.push(
withName<SelectedRelationNameT, RelationSchemasT, VariantsT>(
definition.name,
),
)
}
if (definition.performanceEntryName) {
matchers.push(withPerformanceEntryName(definition.performanceEntryName))
}
if (definition.type) {
matchers.push(withType(definition.type))
}
if (definition.status) {
matchers.push(withStatus(definition.status))
}
if (definition.attributes) {
matchers.push(withAttributes(definition.attributes))
}
if (definition.id) {
matchers.push(withId(definition.id))
}
if (definition.matchingRelations) {
matchers.push(withMatchingRelations(definition.matchingRelations))
}
if (definition.occurrence) {
matchers.push(withOccurrence(definition.occurrence))
}
if (definition.isIdle) {
matchers.push(whenIdle(definition.isIdle))
}
if (definition.label) {
matchers.push(withLabel(definition.label))
}
if (definition.fn) {
matchers.push(definition.fn)
}
let combined: SpanMatcherFn<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>
// Check if definition has oneOf property
if (definition.oneOf) {
// Convert each definition in oneOf array to a matcher and combine with OR
const oneOfMatchers = definition.oneOf.map((def) =>
fromDefinition<SelectedRelationNameT, RelationSchemasT, VariantsT>(def),
)
combined = withAllConditions(
...matchers,
withOneOfConditions(...oneOfMatchers),
)
} else if (definition.not) {
// Handle the negation case
const notMatcher = fromDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>(definition.not)
// If there are other matchers, combine them with AND and then negate the result
// eslint-disable-next-line unicorn/prefer-ternary
if (matchers.length > 0) {
combined = withAllConditions(...matchers, not(notMatcher))
} else {
// If there are no other matchers, just negate the single matcher
combined = not(notMatcher)
}
} else {
combined = withAllConditions(...matchers)
}
combined.fromDefinition = definition
// add public tags:
if (typeof definition.continueWithErrorStatus === 'boolean') {
combined.continueWithErrorStatus = definition.continueWithErrorStatus
}
if (typeof definition.nthMatch === 'number') {
combined.nthMatch = definition.nthMatch
}
if (typeof definition.lowestIndexToConsider === 'number') {
combined.lowestIndexToConsider = definition.lowestIndexToConsider
}
if (typeof definition.highestIndexToConsider === 'number') {
combined.highestIndexToConsider = definition.highestIndexToConsider
}
return combined
}
/**
* Evaluates a span matcher against an entry array.
* Respects matching index, lowestIndexToConsider, and highestIndexToConsider.
*/
export function findMatchingSpan<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
const RecordedItem extends SpanAndAnnotationForMatching<RelationSchemasT>,
>(
matcher: SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT>,
recordedItemsArray: readonly RecordedItem[],
context:
| TraceContext<SelectedRelationNameT, RelationSchemasT, VariantsT>
| undefined,
/** config argument can be used to override tags from matcher: */
{
lowestIndexToConsider = matcher.lowestIndexToConsider ?? 0,
highestIndexToConsider:
highestIndexToConsiderInput = matcher.highestIndexToConsider,
nthMatch = matcher.nthMatch,
}: PublicSpanMatcherTags = {},
): RecordedItem | undefined {
const highestIndexToConsider =
highestIndexToConsiderInput === undefined
? recordedItemsArray.length - 1
: Math.min(highestIndexToConsiderInput, recordedItemsArray.length - 1)
let matchedCount = 0
// For positive or undefined indices - find with specified index offset
if (nthMatch === undefined || nthMatch >= 0) {
for (let i = lowestIndexToConsider; i <= highestIndexToConsider; i++) {
const spanAndAnnotation = recordedItemsArray[i]!
if (matcher(spanAndAnnotation, context)) {
if (nthMatch === undefined || nthMatch === matchedCount) {
return spanAndAnnotation
}
matchedCount++
}
}
// we didn't find a match with the specified index
return undefined
}
// For negative indices - iterate from the end
// If nthMatch is -1, we need the last match (index 0 from reverse)
// If nthMatch is -2, we need the second-to-last match (index 1 from reverse), etc.
const targetIndex = Math.abs(nthMatch) - 1
// Iterate from the end of the array
// TODO: I'm wondering if we should sort recordedItemsArrayReversed by the end time...?
// For that matter, should recordedItemsArray be sorted by their start time?
// If yes, it might be good to do this in createTraceRecording and pass in both recordedItemsArray and recordedItemsArrayReversed pre-sorted, so we don't sort every time we need to calculate a computed span.
for (let i = highestIndexToConsider; i >= 0; i--) {
const spanAndAnnotation = recordedItemsArray[i]!
if (matcher(spanAndAnnotation, context)) {
if (matchedCount === targetIndex) {
return spanAndAnnotation
}
matchedCount++
}
}
return undefined
}