@zilliz/milvus2-sdk-node
Version:
Official Node.js SDK for [Milvus](https://github.com/milvus-io/milvus) vector database. Provides gRPC and HTTP clients for vector similarity search, metadata filtering, and full collection/index/user management.
1,015 lines • 43.4 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.newClientRequestId = exports.ClientTelemetryManager = void 0;
const crypto_1 = __importDefault(require("crypto"));
const os_1 = __importDefault(require("os"));
const async_hooks_1 = require("async_hooks");
const grpc_js_1 = require("@grpc/grpc-js");
const MAX_UNIMPLEMENTED_BACKOFF_MS = 30 * 60 * 1000;
// Node turns delays larger than a signed 32-bit integer into a 1ms timer. Split long
// heartbeat intervals into safe, cancellable chunks so a valid server-pushed interval
// cannot turn into a tight heartbeat loop.
const MAX_TIMER_DELAY_MS = 2147483647;
const SAMPLE_BUFFER_SIZE = 1000;
// History needs enough distribution shape to aggregate percentiles across windows,
// but retaining the full live p99 ring for every snapshot would exceed 200 MiB at
// the one-hour/one-second hard cap. Keep 128 sorted, equidistant quantiles (including
// min/max): seven operations x 4096 windows is about 28 MiB of raw doubles.
const HISTORY_SAMPLE_BUFFER_SIZE = 128;
const HISTORY_RETENTION_MS = 60 * 60 * 1000;
// Preserve the 3601 boundary-inclusive windows produced by a full hour at a one-second
// heartbeat while still bounding memory if a server pushes a sub-second interval.
const MAX_HISTORY_SNAPSHOTS = 4096;
const MAX_REPLY_BYTES = 1024 * 1024;
// Fixed-point unit for accumulating a fractional sampling rate. A rate becomes an integer
// step of this many units, so the smallest rate that still samples is 1e-9 -- far below
// anything an operator would set, which is the point: a configured rate must never round
// down to "off".
const SAMPLING_SCALE = 1000000000;
const PUSH_CONFIG_KEYS = new Set([
'enabled',
'heartbeat_interval_ms',
'sampling_rate',
]);
class MetricBucket {
constructor() {
this.requests = 0;
this.successes = 0;
this.failures = 0;
this.totalLatencyMs = 0;
this.maxLatencyMs = 0;
this.samples = [];
}
record(latencyMs, success) {
this.requests += 1;
this.successes += success ? 1 : 0;
this.failures += success ? 0 : 1;
this.totalLatencyMs += latencyMs;
this.maxLatencyMs = Math.max(this.maxLatencyMs, latencyMs);
this.samples.push(latencyMs);
if (this.samples.length > SAMPLE_BUFFER_SIZE) {
this.samples.shift();
}
}
snapshot() {
if (this.requests === 0) {
return undefined;
}
const sorted = [...this.samples].sort((left, right) => left - right);
const p99 = sorted.length
? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.99))]
: 0;
return {
request_count: this.requests,
success_count: this.successes,
error_count: this.failures,
avg_latency_ms: this.totalLatencyMs / this.requests,
p99_latency_ms: p99,
max_latency_ms: this.maxLatencyMs,
};
}
}
class OperationCollector {
constructor() {
this.global = new MetricBucket();
this.collections = new Map();
}
record(collection, latencyMs, success) {
this.global.record(latencyMs, success);
if (collection) {
let bucket = this.collections.get(collection);
if (!bucket) {
bucket = new MetricBucket();
this.collections.set(collection, bucket);
}
bucket.record(latencyMs, success);
}
}
snapshot(operation) {
const global = this.global.snapshot();
if (!global) {
return undefined;
}
const globalLatencySamples = retainQuantileSamples(this.global.samples);
const collectionMetrics = {};
for (const [name, bucket] of this.collections) {
const metric = bucket.snapshot();
if (metric) {
collectionMetrics[name] = metric;
}
}
this.global = new MetricBucket();
this.collections.clear();
return {
metric: {
operation,
global,
collection_metrics: collectionMetrics,
},
globalLatencySamples,
};
}
}
class ClientTelemetryManager {
constructor(options) {
var _a, _b, _c, _d;
this.ready = false;
this.configHash = '';
this.lastCommandTimestamp = 0;
this.collectors = new Map();
this.errors = [];
this.snapshots = [];
// Kept outside TelemetrySnapshot so samples never enter heartbeat/detail/public JSON.
// Pruning explicitly deletes matching entries to enforce the hard memory bound.
this.snapshotLatencySamples = new Map();
this.pendingReplies = [];
this.executedCommands = new Map();
this.handlers = new Map();
this.enabledCollections = new Set();
this.allCollectionsEnabled = false;
// Carries the fractional sampling rate between calls, in SAMPLING_SCALE units: each
// operation adds the rate and the one that pushes it past a whole unit is the one
// sampled. See shouldSample.
this.samplingAccum = 0;
this.unsupportedStreak = 0;
this.stopped = false;
this.lastSnapshotEnd = 0;
// Command handlers may be asynchronous and ProcessCommands is public. Keep whole batches
// on one queue so two concurrent heartbeats/callers cannot both execute the same ID after
// observing it as absent.
this.commandQueue = Promise.resolve();
// Awaiting processCommands() from a handler would enqueue work behind the batch that is
// currently awaiting that handler. Track the active handler context so recursive use can
// fail as a correlated command reply instead of creating a promise cycle. The mutable
// flag lets work scheduled for after a completed handler use the public queue normally.
this.commandHandlerScope = new async_hooks_1.AsyncLocalStorage();
const config = options.config || {};
this.sender = options.sender;
this.sdkVersion = options.sdkVersion || '';
this.userProvider = options.userProvider || (() => '');
this.databaseProvider = options.databaseProvider || (() => '');
this.configProvider = options.configProvider || (() => ({}));
this.senderEpochProvider = options.senderEpochProvider || (() => 0);
if (config.enabled !== undefined && typeof config.enabled !== 'boolean') {
throw new Error('enabled must be a boolean');
}
this.enabled = (_a = config.enabled) !== null && _a !== void 0 ? _a : true;
// Milliseconds between heartbeats, and therefore the metrics window: each heartbeat
// carries the operations since the last one. The coordinator answers a telemetry query
// from the window before the newest, so what a caller reads is between one and two
// intervals old.
this.heartbeatIntervalMs = Number((_b = config.heartbeatIntervalMs) !== null && _b !== void 0 ? _b : 10000);
if (!Number.isFinite(this.heartbeatIntervalMs) ||
this.heartbeatIntervalMs <= 0) {
throw new Error('heartbeatIntervalMs must be a finite positive number');
}
const samplingRate = (_c = config.samplingRate) !== null && _c !== void 0 ? _c : 1;
if (typeof samplingRate !== 'number' || !Number.isFinite(samplingRate)) {
throw new Error('samplingRate must be a finite number');
}
this.samplingRate = clamp(samplingRate, 0, 1);
const errorMaxCount = (_d = config.errorMaxCount) !== null && _d !== void 0 ? _d : 100;
if (typeof errorMaxCount !== 'number' ||
!Number.isSafeInteger(errorMaxCount) ||
errorMaxCount <= 0) {
throw new Error('errorMaxCount must be a positive integer');
}
this.errorMaxCount = errorMaxCount;
this.stableClientId = Boolean(config.clientId);
this.clientId = config.clientId || crypto_1.default.randomUUID();
this.registerDefaultHandlers();
}
start() {
if (this.ready) {
return;
}
this.ready = true;
if (!this.enabled) {
return;
}
void this.heartbeatLoop();
}
setSdkVersion(version) {
this.sdkVersion = version;
}
stop() {
this.stopped = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
}
isSupported() {
return this.unsupportedStreak === 0;
}
getConfig() {
return {
enabled: this.enabled,
heartbeatIntervalMs: this.heartbeatIntervalMs,
samplingRate: this.samplingRate,
errorMaxCount: this.errorMaxCount,
clientId: this.clientId,
};
}
registerCommandHandler(type, handler) {
this.handlers.set(type, handler);
}
recordOperation(record) {
if (!this.enabled || !this.shouldSample()) {
return;
}
const latencyMs = Math.max(0, performance.now() - record.startTime);
const collection = record.collection &&
(this.allCollectionsEnabled ||
this.enabledCollections.has(record.collection))
? record.collection
: '';
let collector = this.collectors.get(record.operation);
if (!collector) {
collector = new OperationCollector();
this.collectors.set(record.operation, collector);
}
collector.record(collection, latencyMs, !record.error);
if (record.error) {
this.errors.push({
timestamp: Date.now(),
operation: record.operation,
error_msg: errorMessage(record.error),
collection: record.collection || undefined,
request_id: record.requestId || undefined,
});
while (this.errors.length > this.errorMaxCount) {
this.errors.shift();
}
}
}
getRecentErrors(maxCount = 100) {
return [...this.errors].reverse().slice(0, maxCount);
}
getMetricsSnapshots() {
this.pruneSnapshots(Date.now());
return [...this.snapshots];
}
processCommands(commands) {
var _a;
if ((_a = this.commandHandlerScope.getStore()) === null || _a === void 0 ? void 0 : _a.active) {
const rejected = Promise.reject(new Error('processCommands cannot be called recursively from a command handler'));
// Preserve the rejection for callers that await it, but attach a handler immediately
// so fire-and-forget recursion cannot surface as an unhandled rejection.
void rejected.catch(() => undefined);
return rejected;
}
return this.enqueueCommandBatch(commands);
}
enqueueCommandBatch(commands, expectedSenderEpoch) {
const queued = this.commandQueue.then(() => this.processCommandBatch(commands, expectedSenderEpoch));
// A custom handler is isolated by handleCommand, but keep the queue usable even if a
// future batch-level change throws unexpectedly.
this.commandQueue = queued.catch(() => undefined);
return queued;
}
processCommandBatch(commands, expectedSenderEpoch) {
return __awaiter(this, void 0, void 0, function* () {
if (expectedSenderEpoch !== undefined &&
expectedSenderEpoch !== this.senderEpochProvider()) {
return;
}
const previousTimestamp = this.lastCommandTimestamp;
let maxTimestamp = previousTimestamp;
let hasPersistent = false;
for (const command of commands) {
const createTime = Number(command.create_time || 0);
maxTimestamp = Math.max(maxTimestamp, createTime);
hasPersistent || (hasPersistent = Boolean(command.persistent));
if (createTime < previousTimestamp) {
this.pendingReplies.push(successReply(command.command_id));
continue;
}
if (this.executedCommands.has(command.command_id)) {
this.pendingReplies.push(successReply(command.command_id));
continue;
}
const reply = yield this.handleCommand(command);
this.executedCommands.set(command.command_id, createTime);
this.pendingReplies.push(reply);
if (expectedSenderEpoch !== undefined &&
expectedSenderEpoch !== this.senderEpochProvider()) {
// The completed handler may itself have changed the live endpoint. Retain its ID
// and correlated reply so redelivery cannot repeat a non-idempotent prefix, but do
// not apply the retired endpoint's remaining commands or commit its cursor/hash.
return;
}
}
// Keep IDs at the new cursor timestamp: timestamp filtering only rejects commands
// strictly older than the cursor, so equal-timestamp redeliveries still need ID-based
// deduplication. Entries below the new cursor can be discarded safely.
for (const [id, timestamp] of this.executedCommands) {
if (timestamp < maxTimestamp) {
this.executedCommands.delete(id);
}
}
if (hasPersistent) {
this.configHash = ClientTelemetryManager.calculateConfigHash(commands);
}
this.lastCommandTimestamp = Math.max(this.lastCommandTimestamp, maxTimestamp);
});
}
static calculateConfigHash(commands) {
const persistent = commands
.filter(command => command.persistent)
.sort((left, right) => compareUtf8(left.command_id, right.command_id));
if (!persistent.length) {
return '';
}
const hash = crypto_1.default.createHash('sha256');
for (const command of persistent) {
hash.update(command.command_id);
hash.update(command.command_type);
hash.update(payloadBuffer(command.payload));
}
return hash.digest('hex').slice(0, 16);
}
heartbeatLoop() {
return __awaiter(this, void 0, void 0, function* () {
try {
this.createSnapshot();
yield this.sendHeartbeat();
}
catch (error) {
// Telemetry is an optional background control plane. An unexpected collector,
// serializer, command, or transport failure must not terminate the loop forever.
this.lastHeartbeatError = error;
}
finally {
if (this.stopped) {
return;
}
this.scheduleNextHeartbeat(this.nextHeartbeatDelay());
}
});
}
scheduleNextHeartbeat(remainingDelayMs) {
var _a, _b;
const chunk = Math.min(remainingDelayMs, MAX_TIMER_DELAY_MS);
this.timer = setTimeout(() => {
this.timer = undefined;
if (this.stopped) {
return;
}
const remaining = Math.max(0, remainingDelayMs - chunk);
if (remaining > 0) {
this.scheduleNextHeartbeat(remaining);
return;
}
void this.heartbeatLoop();
}, chunk);
// Telemetry is best-effort background work and must not keep an otherwise idle Node
// process alive.
(_b = (_a = this.timer).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
}
nextHeartbeatDelay() {
if (this.unsupportedStreak <= 0) {
return this.heartbeatIntervalMs;
}
return Math.max(this.heartbeatIntervalMs, Math.min(MAX_UNIMPLEMENTED_BACKOFF_MS, this.heartbeatIntervalMs * Math.pow(2, this.unsupportedStreak)));
}
sendHeartbeat() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const latest = this.snapshots[this.snapshots.length - 1];
const replies = [...this.pendingReplies];
const reserved = {
client_id: this.clientId,
client_id_stable: String(this.stableClientId),
};
const database = this.databaseProvider();
if (database) {
reserved.db_name = database;
}
let response;
const senderEpoch = this.senderEpochProvider();
try {
response = yield this.sender({
client_info: {
sdk_type: 'nodejs',
sdk_version: this.sdkVersion,
local_time: new Date().toISOString(),
user: this.userProvider(),
host: os_1.default.hostname(),
reserved,
},
report_timestamp: Date.now(),
// Do not resend the last enabled snapshot while collection is disabled. Replies,
// config hash, cursor and commands remain active as the control plane.
metrics: this.enabled
? this.filterMetricsForWire((latest === null || latest === void 0 ? void 0 : latest.metrics) || [])
: [],
command_replies: replies,
config_hash: this.configHash,
last_command_timestamp: this.lastCommandTimestamp,
});
}
catch (error) {
if (senderEpoch !== this.senderEpochProvider()) {
return;
}
this.lastHeartbeatError = error;
if ((error === null || error === void 0 ? void 0 : error.code) === grpc_js_1.status.UNIMPLEMENTED) {
this.unsupportedStreak += 1;
}
return;
}
// A global-cluster failover can publish a new telemetry transport while this
// heartbeat is awaiting the old endpoint. Treat that old response as if it never
// arrived: do not acknowledge replies, reset backoff/errors, execute commands, or
// mutate any command/config history.
if (senderEpoch !== this.senderEpochProvider()) {
return;
}
// Any real response proves the RPC exists. Reset an old UNIMPLEMENTED streak before
// checking the business status, which may fail while the service is still starting.
this.unsupportedStreak = 0;
if (!responseSucceeded(response)) {
this.lastHeartbeatError = new Error(((_a = response === null || response === void 0 ? void 0 : response.status) === null || _a === void 0 ? void 0 : _a.reason) || 'client heartbeat failed');
return;
}
this.pendingReplies.splice(0, replies.length);
this.lastHeartbeatError = undefined;
yield this.enqueueCommandBatch((response === null || response === void 0 ? void 0 : response.commands) || [], senderEpoch);
});
}
/**
* Decide whether this operation is recorded, spreading the sampled ones evenly rather
* than in runs.
*
* Each call adds the rate to an accumulator and samples on the call that carries it
* across a whole unit: at 0.25 that is every fourth operation. The ratio has to hold
* over any stretch of calls, not only over a long one -- metrics are reported per
* heartbeat window, and a window is tens or hundreds of operations, so sampling a
* contiguous run would make each window either complete or empty.
*/
shouldSample() {
if (this.samplingRate >= 1) {
return true;
}
if (this.samplingRate <= 0) {
return false;
}
// A rate too small to represent still means "sample rarely", never "sample never":
// silently disabling telemetry for a positive rate is the one outcome nobody could
// have intended.
const step = Math.max(1, Math.floor(this.samplingRate * SAMPLING_SCALE));
this.samplingAccum += step;
if (this.samplingAccum < SAMPLING_SCALE) {
return false;
}
// Keep only the remainder rather than letting the accumulator grow without bound: a
// JavaScript number is exact only to 2^53, which at a few hundred million units per
// operation is reached within a day of steady traffic, after which the comparison
// would start losing operations silently.
this.samplingAccum -= SAMPLING_SCALE;
return true;
}
createSnapshot() {
if (!this.enabled) {
return;
}
const metrics = [];
const latencySamples = {};
for (const [operation, collector] of this.collectors) {
const collected = collector.snapshot(operation);
if (collected) {
metrics.push(collected.metric);
latencySamples[operation] = collected.globalLatencySamples;
}
}
const now = Date.now();
const start = !this.lastSnapshotEnd || this.lastSnapshotEnd > now
? now - this.heartbeatIntervalMs
: this.lastSnapshotEnd;
this.lastSnapshotEnd = now;
const snapshot = { timestamp: start, end_time: now, metrics };
this.snapshots.push(snapshot);
this.snapshotLatencySamples.set(snapshot, latencySamples);
this.pruneSnapshots(now);
}
pruneSnapshots(now) {
const cutoff = now - HISTORY_RETENTION_MS;
while (this.snapshots.length && this.snapshots[0].end_time < cutoff) {
const expired = this.snapshots.shift();
if (expired) {
this.snapshotLatencySamples.delete(expired);
}
}
while (this.snapshots.length > MAX_HISTORY_SNAPSHOTS) {
const expired = this.snapshots.shift();
if (expired) {
this.snapshotLatencySamples.delete(expired);
}
}
}
filterMetricsForWire(metrics) {
return metrics.map(operation => {
const collectionMetrics = {};
for (const [collection, metric] of Object.entries(operation.collection_metrics)) {
if (this.allCollectionsEnabled ||
this.enabledCollections.has(collection)) {
collectionMetrics[collection] = metric;
}
}
return Object.assign(Object.assign({}, operation), { collection_metrics: collectionMetrics });
});
}
handleCommand(command) {
return __awaiter(this, void 0, void 0, function* () {
const handler = this.handlers.get(command.command_type);
if (!handler) {
return failedReply(command.command_id, `unknown command type: ${command.command_type}`);
}
const handlerScope = { active: true };
try {
const reply = yield this.commandHandlerScope.run(handlerScope, () => handler(command));
if (!reply || typeof reply !== 'object') {
return failedReply(command.command_id, 'command handler returned no reply');
}
// command_id is the server's correlation key. A custom handler may customize the
// result, but it may never acknowledge a different command.
return Object.assign(Object.assign({}, reply), { command_id: command.command_id });
}
catch (error) {
return failedReply(command.command_id, errorMessage(error));
}
finally {
handlerScope.active = false;
}
});
}
registerDefaultHandlers() {
this.registerCommandHandler('push_config', command => {
const payload = parsePayload(command);
const enabled = optionalBoolean(payload, 'enabled');
const heartbeatIntervalMs = optionalInteger(payload, 'heartbeat_interval_ms');
const samplingRate = optionalFiniteNumber(payload, 'sampling_rate');
// Validate the complete payload before changing any field. A failed command must not
// leave an earlier key applied while reporting that the whole command failed.
if (heartbeatIntervalMs !== undefined && heartbeatIntervalMs <= 0) {
throw new Error('heartbeat_interval_ms must be a positive integer');
}
const applied = [];
if (enabled !== undefined) {
applied.push('enabled');
}
if (heartbeatIntervalMs !== undefined) {
applied.push('heartbeat_interval_ms');
}
if (samplingRate !== undefined) {
applied.push('sampling_rate');
}
const ignored = Object.keys(payload)
.filter(key => !PUSH_CONFIG_KEYS.has(key))
.sort(compareUtf8);
if (enabled !== undefined) {
this.enabled = enabled;
}
if (heartbeatIntervalMs !== undefined) {
this.heartbeatIntervalMs = heartbeatIntervalMs;
}
if (samplingRate !== undefined) {
this.samplingRate = clamp(samplingRate, 0, 1);
}
return successReply(command.command_id, Buffer.from(JSON.stringify(Object.assign({ applied }, (ignored.length ? { ignored } : {})))));
});
this.registerCommandHandler('collection_metrics', command => {
var _a, _b;
if (!payloadBuffer(command.payload).length) {
return successReply(command.command_id, Buffer.from(JSON.stringify({
enabled_collections: [...this.enabledCollections].sort(),
all_collections_enabled: this.allCollectionsEnabled,
})));
}
const payload = parsePayload(command);
const enabled = (_a = optionalBoolean(payload, 'enabled')) !== null && _a !== void 0 ? _a : false;
const collections = (_b = optionalStringArray(payload, 'collections')) !== null && _b !== void 0 ? _b : [];
// metrics_types is not acted on yet, but it is part of the protocol payload and must
// still have the same typed-JSON behavior as the Go SDK.
optionalStringArray(payload, 'metrics_types');
const wildcard = collections.includes('*');
if (enabled) {
if (!collections.length) {
throw new Error('collections list cannot be empty when enabled=true');
}
if (wildcard) {
this.allCollectionsEnabled = true;
}
else {
collections.forEach(name => this.enabledCollections.add(name));
}
}
else if (wildcard || !collections.length) {
this.allCollectionsEnabled = false;
this.enabledCollections.clear();
}
else {
collections.forEach(name => this.enabledCollections.delete(name));
}
return successReply(command.command_id);
});
this.registerCommandHandler('show_errors', command => {
const payload = parsePayload(command);
const configuredMaxCount = optionalInteger(payload, 'max_count');
const maxCount = configuredMaxCount !== undefined && configuredMaxCount > 0
? configuredMaxCount
: 100;
let errors = this.getRecentErrors(maxCount).map(error => (Object.assign({}, error)));
if (!errors.length) {
return successReply(command.command_id);
}
let encoded = Buffer.from(JSON.stringify(errors));
while (encoded.length > MAX_REPLY_BYTES && errors.length > 1) {
errors = errors.slice(0, Math.max(1, Math.floor(errors.length / 2)));
encoded = Buffer.from(JSON.stringify(errors));
}
if (encoded.length > MAX_REPLY_BYTES && errors.length === 1) {
encoded = truncateSingleErrorReply(errors[0], MAX_REPLY_BYTES);
}
if (encoded.length > MAX_REPLY_BYTES) {
throw new Error('show_errors response exceeds the 1MB payload limit');
}
return successReply(command.command_id, encoded);
});
this.registerCommandHandler('get_config', command => {
const userConfig = Object.assign({}, this.configProvider());
['password', 'token', 'api_key', 'authorization'].forEach(key => delete userConfig[key]);
Object.assign(userConfig, {
telemetry_enabled: this.enabled,
telemetry_heartbeat_interval_ms: this.heartbeatIntervalMs,
telemetry_sampling_rate: this.samplingRate,
enabled_collections: this.allCollectionsEnabled
? ['*']
: [...this.enabledCollections].sort(),
all_collections_enabled: this.allCollectionsEnabled,
});
return successReply(command.command_id, Buffer.from(JSON.stringify({ user_config: userConfig })));
});
this.registerCommandHandler('show_latency_history', command => {
const payload = parsePayload(command);
if (typeof payload.start_time !== 'string' ||
typeof payload.end_time !== 'string') {
throw new Error('payload is required with start_time and end_time');
}
if ('detail' in payload && typeof payload.detail !== 'boolean') {
throw new Error('detail must be a boolean');
}
const start = parseRfc3339(payload.start_time, 'start_time');
const end = parseRfc3339(payload.end_time, 'end_time');
if (end < start) {
throw new Error('end_time must be after start_time');
}
if (end - start > 60 * 60 * 1000) {
throw new Error('time range cannot exceed 1 hour');
}
this.pruneSnapshots(Date.now());
const snapshots = this.snapshots.filter(snapshot => snapshot.end_time >= start && snapshot.timestamp <= end);
const body = payload.detail === true
? {
snapshots: detailSnapshots(snapshots),
total_snapshots: snapshots.length,
}
: aggregateSnapshots(snapshots, start, end, snapshot => this.snapshotLatencySamples.get(snapshot) || {});
const encoded = Buffer.from(JSON.stringify(body));
if (encoded.length > MAX_REPLY_BYTES) {
throw new Error('response too large, try a smaller time range');
}
return successReply(command.command_id, encoded);
});
}
}
exports.ClientTelemetryManager = ClientTelemetryManager;
function newClientRequestId() {
let value;
do {
value = crypto_1.default.randomBytes(16);
} while (value.every(byte => byte === 0));
return value.toString('hex');
}
exports.newClientRequestId = newClientRequestId;
function payloadBuffer(payload) {
if (!payload) {
return Buffer.alloc(0);
}
return typeof payload === 'string'
? Buffer.from(payload)
: Buffer.from(payload);
}
function parsePayload(command) {
const payload = payloadBuffer(command.payload);
if (!payload.length) {
return {};
}
const parsed = JSON.parse(payload.toString());
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('command payload must be a JSON object');
}
return parsed;
}
function truncateSingleErrorReply(error, maxBytes) {
const mutableError = error;
const fields = ['error_msg', 'collection', 'request_id', 'operation'].sort((left, right) => Buffer.byteLength(String(mutableError[right] || '')) -
Buffer.byteLength(String(mutableError[left] || '')));
const suffix = '...(truncated)';
let encoded = Buffer.from(JSON.stringify([error]));
for (const field of fields) {
if (encoded.length <= maxBytes) {
return encoded;
}
const original = mutableError[field];
if (typeof original !== 'string' || original.length === 0) {
continue;
}
// Halve an original prefix, never the already suffixed value. The prefix length strictly
// decreases to zero, so this terminates even when another field caused the overflow.
let prefixLength = original.length;
while (encoded.length > maxBytes && prefixLength > 0) {
prefixLength = Math.floor(prefixLength / 2);
const prefix = unicodeSafePrefix(original, prefixLength);
mutableError[field] = prefix ? `${prefix}${suffix}` : '';
encoded = Buffer.from(JSON.stringify([error]));
}
if (encoded.length <= maxBytes) {
return encoded;
}
if (field === 'collection' || field === 'request_id') {
delete mutableError[field];
}
encoded = Buffer.from(JSON.stringify([error]));
}
return encoded;
}
function unicodeSafePrefix(value, length) {
let end = Math.min(value.length, Math.max(0, length));
if (end > 0 &&
end < value.length &&
value.charCodeAt(end - 1) >= 0xd800 &&
value.charCodeAt(end - 1) <= 0xdbff &&
value.charCodeAt(end) >= 0xdc00 &&
value.charCodeAt(end) <= 0xdfff) {
end -= 1;
}
return value.slice(0, end);
}
function optionalBoolean(payload, key) {
if (!(key in payload)) {
return undefined;
}
if (typeof payload[key] !== 'boolean') {
throw new Error(`${key} must be a boolean`);
}
return payload[key];
}
function optionalFiniteNumber(payload, key) {
if (!(key in payload)) {
return undefined;
}
const value = payload[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error(`${key} must be a finite number`);
}
return value;
}
function optionalInteger(payload, key) {
const value = optionalFiniteNumber(payload, key);
if (value !== undefined && !Number.isSafeInteger(value)) {
throw new Error(`${key} must be an integer`);
}
return value;
}
function optionalStringArray(payload, key) {
if (!(key in payload)) {
return undefined;
}
const value = payload[key];
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
throw new Error(`${key} must be an array of strings`);
}
return value;
}
function compareUtf8(left, right) {
return Buffer.compare(Buffer.from(left), Buffer.from(right));
}
const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:Z|([+-])(\d{2}):(\d{2}))$/;
function parseRfc3339(value, field) {
const match = RFC3339_PATTERN.exec(value);
if (!match) {
throw new Error(`invalid ${field} format, expected RFC3339`);
}
const [, year, month, day, hour, minute, second, , , zoneHour, zoneMinute] = match;
const numericYear = Number(year);
const numericMonth = Number(month);
const numericDay = Number(day);
const numericHour = Number(hour);
const numericMinute = Number(minute);
const numericSecond = Number(second);
const numericZoneHour = zoneHour === undefined ? 0 : Number(zoneHour);
const numericZoneMinute = zoneMinute === undefined ? 0 : Number(zoneMinute);
const leapYear = numericYear % 4 === 0 &&
(numericYear % 100 !== 0 || numericYear % 400 === 0);
const daysInMonth = [
31,
leapYear ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
][numericMonth - 1];
if (numericMonth < 1 ||
numericMonth > 12 ||
numericDay < 1 ||
numericDay > (daysInMonth || 0) ||
numericHour > 23 ||
numericMinute > 59 ||
numericSecond > 59 ||
numericZoneHour > 23 ||
numericZoneMinute > 59) {
throw new Error(`invalid ${field} format, expected RFC3339`);
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
throw new Error(`invalid ${field} format, expected RFC3339`);
}
return parsed;
}
function successReply(commandId, payload = Buffer.alloc(0)) {
return { command_id: commandId, success: true, payload };
}
function failedReply(commandId, error) {
return { command_id: commandId, success: false, error_message: error };
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
function responseSucceeded(response) {
const status = response === null || response === void 0 ? void 0 : response.status;
if (!status) {
return true;
}
const code = Number(status.code || 0);
const errorCode = status.error_code;
return (code === 0 &&
(errorCode === undefined ||
errorCode === 0 ||
errorCode === '0' ||
errorCode === 'Success' ||
errorCode === 'SUCCESS'));
}
function retainQuantileSamples(samples) {
const sorted = [...samples].sort((left, right) => left - right);
if (sorted.length <= HISTORY_SAMPLE_BUFFER_SIZE) {
return Float64Array.from(sorted);
}
return Float64Array.from({ length: HISTORY_SAMPLE_BUFFER_SIZE }, (_, index) => {
const sourceIndex = Math.round((index * (sorted.length - 1)) / (HISTORY_SAMPLE_BUFFER_SIZE - 1));
return sorted[sourceIndex];
});
}
function weightedPercentile(groups, target) {
const heap = [];
const push = (cursor) => {
heap.push(cursor);
let index = heap.length - 1;
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (heap[parent].latency <= cursor.latency) {
break;
}
heap[index] = heap[parent];
index = parent;
}
heap[index] = cursor;
};
const pop = () => {
const first = heap[0];
const last = heap.pop();
if (heap.length && last) {
let index = 0;
while (true) {
const left = index * 2 + 1;
if (left >= heap.length) {
break;
}
const right = left + 1;
const child = right < heap.length && heap[right].latency < heap[left].latency
? right
: left;
if (heap[child].latency >= last.latency) {
break;
}
heap[index] = heap[child];
index = child;
}
heap[index] = last;
}
return first;
};
for (const group of groups) {
if (group.samples.length) {
push(Object.assign(Object.assign({}, group), { index: 0, latency: group.samples[0] }));
}
}
let cumulative = 0;
let percentile = 0;
while (heap.length) {
const cursor = pop();
cumulative += cursor.weight;
percentile = cursor.latency;
if (cumulative > target) {
return percentile;
}
cursor.index += 1;
if (cursor.index < cursor.samples.length) {
cursor.latency = cursor.samples[cursor.index];
push(cursor);
}
}
return percentile;
}
function aggregateSnapshots(snapshots, start, end, latencySamplesFor) {
var _a;
const totals = {};
for (const snapshot of snapshots) {
const latencySamples = latencySamplesFor(snapshot);
for (const operation of snapshot.metrics) {
const metric = operation.global;
const total = (totals[_a = operation.operation] || (totals[_a] = {
request_count: 0,
success_count: 0,
error_count: 0,
weighted_avg: 0,
max_latency_ms: 0,
latency_sample_groups: [],
}));
total.request_count += metric.request_count;
total.success_count += metric.success_count;
total.error_count += metric.error_count;
total.weighted_avg += metric.avg_latency_ms * metric.request_count;
total.max_latency_ms = Math.max(total.max_latency_ms, metric.max_latency_ms);
const samples = latencySamples[operation.operation];
if (samples === null || samples === void 0 ? void 0 : samples.length) {
const weight = metric.request_count / samples.length;
total.latency_sample_groups.push({ samples, weight });
}
}
}
const metrics = {};
for (const [operation, total] of Object.entries(totals)) {
const p99 = weightedPercentile(total.latency_sample_groups, 0.99 * total.request_count);
metrics[operation] = {
request_count: total.request_count,
success_count: total.success_count,
error_count: total.error_count,
avg_latency_ms: total.request_count
? total.weighted_avg / total.request_count
: 0,
p99_latency_ms: p99,
max_latency_ms: total.max_latency_ms,
};
}
return {
aggregated: { start_time: start, end_time: end, metrics },
snapshot_count: snapshots.length,
};
}
function detailSnapshots(snapshots) {
return snapshots.map(snapshot => {
const metrics = {};
for (const operation of snapshot.metrics) {
metrics[operation.operation] = operation.global;
}
return {
timestamp: snapshot.timestamp,
end_time: snapshot.end_time,
metrics,
};
});
}
//# sourceMappingURL=ClientTelemetry.js.map