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
text/typescript
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;
}