UNPKG

hypertune

Version:

[Hypertune](https://www.hypertune.com/) is the most flexible platform for feature flags, A/B testing, analytics and app configuration. Built with full end-to-end type-safety, Git-style version control and local, synchronous, in-memory flag evaluation. Opt

292 lines (264 loc) 7.97 kB
import pRetry from "p-retry"; import { CreateLogsInput, LogType, LogLevel, CountMap, Event, Exposure, LocalLogger, ObjectValue, } from "../types"; import toDimensionTypeEnum from "./toDimensionTypeEnum"; import uniqueId from "./uniqueId"; import getMetadata from "./getMetadata"; import { graphqlTypeNameKey } from "../constants"; /** * `RemoteLogger` provides a granular API to send evaluations, events, exposures * and generic log messages to a remote server. It queues them internally and * sends them to the remote server when the flush method is called manually. * When `flushIntervalMs` option is set to a non null value it also sends them * periodically in the background. */ export default class RemoteLogger { private readonly token: string; private readonly queue: { logs: NonNullable<CreateLogsInput["logs"]>; evaluations: Map< /* Commit ID */ string, Map</* Expression ID */ string, number> >; events: NonNullable<CreateLogsInput["events"]>; exposures: NonNullable<CreateLogsInput["exposures"]>; } = { logs: [], evaluations: new Map(), events: [], exposures: [], }; private readonly createLogs: ( traceId: string, input: CreateLogsInput ) => Promise<void>; private readonly localLogger: LocalLogger; private shouldClose = false; private flushLogsTimeout: NodeJS.Timeout | null = null; constructor({ traceId, token, createLogs, localLogger, flushIntervalMs, }: { traceId: string; token: string; createLogs: ( createLogTraceId: string, input: CreateLogsInput ) => Promise<void>; localLogger: LocalLogger; flushIntervalMs: number | null; }) { this.token = token; this.createLogs = createLogs; this.localLogger = localLogger; this.startFlushIntervals(traceId, flushIntervalMs); } // eslint-disable-next-line max-params public log( type: LogType, level: LogLevel, commitId: string | null, message: string, metadata: object ): void { const { queue } = this; // Avoid memory leak in case we generate lots of logs or fail to flush them. if (queue.logs.length > 1000) { this.localLogger( LogLevel.Debug, "Ignoring log, as more than 1000 in the queue already.", /* metadata */ {} ); return; } queue.logs.push({ commitId, type, level, message, metadataJson: JSON.stringify(metadata), createdAt: new Date().toJSON(), }); } public evaluations(commitId: string, evaluations: CountMap): void { const { queue } = this; let maybeCommitMap = queue.evaluations.get(commitId); if (!maybeCommitMap) { maybeCommitMap = new Map(); queue.evaluations.set(commitId, maybeCommitMap); } const commitMap = maybeCommitMap; Object.entries(evaluations).forEach(([expressionId, count]) => { commitMap.set(expressionId, (commitMap.get(expressionId) ?? 0) + count); }); } public events(commitId: string, events: Event[]): void { const { queue } = this; events.forEach( ({ objectTypeName: eventObjectTypeName, payload: eventPayload }) => queue.events.push({ commitId, eventObjectTypeName, eventPayloadJson: stringifyEventPayload(eventPayload), createdAt: new Date().toJSON(), }) ); } public exposures(commitId: string, exposures: Exposure[]): void { const { queue } = this; exposures.forEach(({ splitId, unitId, assignment, event }) => queue.exposures.push({ commitId, splitId, unitId, assignment: Object.entries(assignment).map(([dimensionId, entry]) => ({ dimensionId, entryType: toDimensionTypeEnum(entry.type), ...(entry.type === "discrete" ? { discreteArmId: entry.armId } : { continuousValue: entry.value }), })), eventObjectTypeName: event?.objectTypeName ?? null, eventPayloadJson: stringifyEventPayload(event?.payload ?? null), createdAt: new Date().toJSON(), }) ); } public async flush(traceId: string): Promise<void> { const { queue, token, createLogs, localLogger } = this; const anythingToFlush = queue.logs.length > 0 || queue.evaluations.size > 0 || queue.events.length > 0 || queue.exposures.length > 0; if (!anythingToFlush) { return; } // Only include 1 random log to ensure the payload doesn't get too big const randomLog = queue.logs.length > 0 ? queue.logs[Math.floor(queue.logs.length * Math.random())] : null; const createLogsInput: CreateLogsInput = { token, idempotencyKey: uniqueId(), logs: randomLog ? [randomLog] : [], evaluations: [...queue.evaluations.entries()].flatMap( ([commitId, commitMap]) => [...commitMap.entries()].map(([expressionId, count]) => ({ commitId, expressionId, count, })) ), events: queue.events, exposures: queue.exposures, }; queue.logs = []; queue.evaluations.clear(); queue.events = []; queue.exposures = []; try { await pRetry( (attemptNumber) => { localLogger( LogLevel.Debug, `Attempt ${attemptNumber} to flush logs to backend...`, /* metadata */ { traceId, createLogsInput } ); return createLogs(traceId, createLogsInput); }, { onFailedAttempt: (error) => { localLogger( LogLevel.Debug, `Attempt ${error.attemptNumber} to flush logs failed. There are ${error.retriesLeft} retries left.`, { traceId, ...getMetadata(error), createLogsInput } ); }, } ); localLogger( LogLevel.Debug, "Successfully flushed logs to backend.", /* metadata */ { traceId, createLogsInput } ); } catch (error) { localLogger( LogLevel.Error, "All attempts to flush logs failed. These logs will be lost.", { traceId, ...getMetadata(error), createLogsInput } ); } } public async close(traceId: string): Promise<void> { this.shouldClose = true; if (this.flushLogsTimeout) { clearTimeout(this.flushLogsTimeout); this.flushLogsTimeout = null; } await this.flush(traceId); } private startFlushIntervals( initTraceId: string, flushIntervalMs: number | null ): void { if (!flushIntervalMs) { this.localLogger(LogLevel.Info, "Not automatically flushing logs.", { traceId: initTraceId, }); return; } // eslint-disable-next-line func-style const flushLogQueue = (): void => { this.flushLogsTimeout = setTimeout(() => { const traceId = uniqueId(); if (this.shouldClose) { this.flushLogsTimeout = null; this.localLogger(LogLevel.Debug, "Stopped flushing log queue.", { traceId, }); return; } this.flush(traceId) .catch((error) => this.localLogger(LogLevel.Error, "Error flushing logs.", { ...getMetadata(error), traceId, }) ) .finally(() => { if (this.shouldClose) { this.flushLogsTimeout = null; this.localLogger(LogLevel.Debug, "Stopped flushing log queue.", { traceId, }); return; } flushLogQueue(); }); }, flushIntervalMs); }; flushLogQueue(); } } function stringifyEventPayload( eventPayload: ObjectValue | null ): string | null { return eventPayload ? JSON.stringify(eventPayload, (key, value) => key === graphqlTypeNameKey ? undefined : value ) : null; }