fix(ts-sdk): take /v1/ping/ off the request critical path (#6788)

This commit is contained in:
pratik
2026-08-04 10:37:50 -07:00
committed by GitHub
parent deca4bd3a5
commit 6052252250
2 changed files with 81 additions and 42 deletions
+67 -35
View File
@@ -83,6 +83,16 @@ interface ClientOptions {
host?: string; host?: string;
} }
interface ClientIdentity {
telemetryId: string;
organizationId: string | number | null;
projectId: string | number | null;
}
// Shares one ping per (host, api key) across clients; FIFO-capped.
const IDENTITY_CACHE_MAX = 50;
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
export default class MemoryClient { export default class MemoryClient {
apiKey: string; apiKey: string;
host: string; host: string;
@@ -91,6 +101,7 @@ export default class MemoryClient {
headers: Record<string, string>; headers: Record<string, string>;
client: any; client: any;
telemetryId: string; telemetryId: string;
private initialized: Promise<void>;
_validateApiKey(): any { _validateApiKey(): any {
if (!this.apiKey) { if (!this.apiKey) {
@@ -122,11 +133,39 @@ export default class MemoryClient {
}); });
this._validateApiKey(); this._validateApiKey();
this.telemetryId = ""; this.telemetryId = "";
this._initializeClient();
// Requests never wait on this; only telemetry does.
this.initialized = this._resolveIdentity();
} }
private async _initializeClient() { // One ping per credential pair per process, shared via identityByCredentials.
private _resolveIdentity(): Promise<void> {
const credentials = `${this.host}\u0000${this.apiKey}`;
let shared = identityByCredentials.get(credentials);
if (!shared) {
shared = this._initializeClient();
if (identityByCredentials.size >= IDENTITY_CACHE_MAX) {
identityByCredentials.delete(
identityByCredentials.keys().next().value!,
);
}
identityByCredentials.set(credentials, shared);
// A failed ping must not be cached, or the process never recovers.
shared.then((identity) => {
if (!identity.telemetryId) identityByCredentials.delete(credentials);
});
}
return shared.then((identity) => {
this.telemetryId = identity.telemetryId;
if (identity.organizationId != null)
this.organizationId = identity.organizationId;
if (identity.projectId != null) this.projectId = identity.projectId;
});
}
private async _initializeClient(): Promise<ClientIdentity> {
try { try {
await this.ping(); await this.ping();
@@ -148,6 +187,12 @@ export default class MemoryClient {
stack: error?.stack || "No stack trace", stack: error?.stack || "No stack trace",
}); });
} }
return {
telemetryId: this.telemetryId,
organizationId: this.organizationId,
projectId: this.projectId,
};
} }
private async _maybeAliasAnonToEmail(): Promise<void> { private async _maybeAliasAnonToEmail(): Promise<void> {
@@ -175,13 +220,18 @@ export default class MemoryClient {
} }
private _captureEvent(methodName: string, args: any[]) { private _captureEvent(methodName: string, args: any[]) {
captureClientEvent(methodName, this, { // Deferred until ping() has resolved telemetryId, off the request path.
success: true, this.initialized
args_count: args.length, .then(() =>
keys: args.length > 0 ? args[0] : [], captureClientEvent(methodName, this, {
}).catch((error: any) => { success: true,
console.error("Failed to capture event:", error); args_count: args.length,
}); keys: args.length > 0 ? args[0] : [],
}),
)
.catch((error: any) => {
console.error("Failed to capture event:", error);
});
} }
async _fetchWithErrorHandling(url: string, options: any): Promise<any> { async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
@@ -262,8 +312,6 @@ export default class MemoryClient {
throw new Error("Cannot process an empty messages payload."); throw new Error("Cannot process an empty messages payload.");
} }
if (this.telemetryId === "") await this.ping();
const payload = this._preparePayload(messages, options); const payload = this._preparePayload(messages, options);
const payloadKeys = Object.keys(payload); const payloadKeys = Object.keys(payload);
this._captureEvent("add", [payloadKeys]); this._captureEvent("add", [payloadKeys]);
@@ -304,7 +352,6 @@ export default class MemoryClient {
); );
} }
if (this.telemetryId === "") await this.ping();
const payload: Record<string, any> = {}; const payload: Record<string, any> = {};
if (text !== undefined) payload.text = text; if (text !== undefined) payload.text = text;
if (metadata !== undefined) payload.metadata = metadata; if (metadata !== undefined) payload.metadata = metadata;
@@ -326,7 +373,6 @@ export default class MemoryClient {
} }
async get(memoryId: string): Promise<Memory> { async get(memoryId: string): Promise<Memory> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get", []); this._captureEvent("get", []);
return this._fetchWithErrorHandling( return this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/`, `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
@@ -340,7 +386,6 @@ export default class MemoryClient {
// Reject top-level entity params - must use filters instead // Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "getAll"); rejectTopLevelEntityParams(options as Record<string, any>, "getAll");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {}); const payloadKeys = Object.keys(options || {});
this._captureEvent("get_all", [payloadKeys]); this._captureEvent("get_all", [payloadKeys]);
const { page, pageSize, filters, ...rest } = options ?? {}; const { page, pageSize, filters, ...rest } = options ?? {};
@@ -369,7 +414,6 @@ export default class MemoryClient {
// Reject top-level entity params - must use filters instead // Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "search"); rejectTopLevelEntityParams(options as Record<string, any>, "search");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {}); const payloadKeys = Object.keys(options || {});
this._captureEvent("search", [payloadKeys]); this._captureEvent("search", [payloadKeys]);
const { filters, ...rest } = options ?? {}; const { filters, ...rest } = options ?? {};
@@ -395,7 +439,6 @@ export default class MemoryClient {
memoryId: string, memoryId: string,
options: DeleteMemoryOptions = {}, options: DeleteMemoryOptions = {},
): Promise<{ message: string }> { ): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete", [Object.keys(options || {})]); this._captureEvent("delete", [Object.keys(options || {})]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options)); const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
// @ts-ignore // @ts-ignore
@@ -412,7 +455,6 @@ export default class MemoryClient {
async deleteAll( async deleteAll(
options: DeleteAllMemoryOptions = {}, options: DeleteAllMemoryOptions = {},
): Promise<{ message: string }> { ): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {}); const payloadKeys = Object.keys(options || {});
this._captureEvent("delete_all", [payloadKeys]); this._captureEvent("delete_all", [payloadKeys]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options)); const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
@@ -429,7 +471,6 @@ export default class MemoryClient {
} }
async history(memoryId: string): Promise<Array<MemoryHistory>> { async history(memoryId: string): Promise<Array<MemoryHistory>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("history", []); this._captureEvent("history", []);
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`, `${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`,
@@ -444,7 +485,6 @@ export default class MemoryClient {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}): Promise<AllUsers> { }): Promise<AllUsers> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("users", []); this._captureEvent("users", []);
let url = `${this.host}/v1/entities/`; let url = `${this.host}/v1/entities/`;
const params: string[] = []; const params: string[] = [];
@@ -464,7 +504,6 @@ export default class MemoryClient {
entity_id: number; entity_id: number;
entity_type: string; entity_type: string;
}): Promise<{ message: string }> { }): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_user", []); this._captureEvent("delete_user", []);
if (!data.entity_type) { if (!data.entity_type) {
data.entity_type = "user"; data.entity_type = "user";
@@ -487,8 +526,6 @@ export default class MemoryClient {
runId?: string; runId?: string;
} = {}, } = {},
): Promise<{ message: string }> { ): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
let to_delete: Array<{ type: string; name: string }> = []; let to_delete: Array<{ type: string; name: string }> = [];
const { userId, agentId, appId, runId } = params; const { userId, agentId, appId, runId } = params;
@@ -514,8 +551,14 @@ export default class MemoryClient {
for (const entity of to_delete) { for (const entity of to_delete) {
try { try {
await this.client.delete( // fetch() reuses the pooled connection; axios here defaulted to
`/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`, // keepAlive: false, one handshake per entity.
await this._fetchWithErrorHandling(
`${this.host}/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
{
method: "DELETE",
headers: this.headers,
},
); );
} catch (error: any) { } catch (error: any) {
throw new APIError( throw new APIError(
@@ -537,7 +580,6 @@ export default class MemoryClient {
} }
async batchUpdate(memories: Array<MemoryUpdateBody>): Promise<string> { async batchUpdate(memories: Array<MemoryUpdateBody>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_update", []); this._captureEvent("batch_update", []);
const memoriesBody = memories.map((memory) => ({ const memoriesBody = memories.map((memory) => ({
memory_id: memory.memoryId, memory_id: memory.memoryId,
@@ -555,7 +597,6 @@ export default class MemoryClient {
} }
async batchDelete(memories: Array<string>): Promise<string> { async batchDelete(memories: Array<string>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_delete", []); this._captureEvent("batch_delete", []);
const memoriesBody = memories.map((memory) => ({ const memoriesBody = memories.map((memory) => ({
memory_id: memory, memory_id: memory,
@@ -572,7 +613,6 @@ export default class MemoryClient {
} }
async getProject(options: ProjectOptions): Promise<ProjectResponse> { async getProject(options: ProjectOptions): Promise<ProjectResponse> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {}); const payloadKeys = Object.keys(options || {});
this._captureEvent("get_project", [payloadKeys]); this._captureEvent("get_project", [payloadKeys]);
const { fields } = options; const { fields } = options;
@@ -598,7 +638,6 @@ export default class MemoryClient {
async updateProject( async updateProject(
prompts: PromptUpdatePayload, prompts: PromptUpdatePayload,
): Promise<Record<string, any>> { ): Promise<Record<string, any>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_project", []); this._captureEvent("update_project", []);
if (!(this.organizationId && this.projectId)) { if (!(this.organizationId && this.projectId)) {
throw new Error( throw new Error(
@@ -619,7 +658,6 @@ export default class MemoryClient {
// WebHooks // WebHooks
async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> { async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_webhooks", []); this._captureEvent("get_webhooks", []);
const project_id = data?.projectId || this.projectId; const project_id = data?.projectId || this.projectId;
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
@@ -632,7 +670,6 @@ export default class MemoryClient {
} }
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> { async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_webhook", []); this._captureEvent("create_webhook", []);
const body = { const body = {
name: webhook.name, name: webhook.name,
@@ -653,7 +690,6 @@ export default class MemoryClient {
async updateWebhook( async updateWebhook(
webhook: WebhookUpdatePayload, webhook: WebhookUpdatePayload,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_webhook", []); this._captureEvent("update_webhook", []);
const body: Record<string, any> = {}; const body: Record<string, any> = {};
if (webhook.name != null) body.name = webhook.name; if (webhook.name != null) body.name = webhook.name;
@@ -673,7 +709,6 @@ export default class MemoryClient {
async deleteWebhook(data: { async deleteWebhook(data: {
webhookId: string; webhookId: string;
}): Promise<{ message: string }> { }): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_webhook", []); this._captureEvent("delete_webhook", []);
const webhook_id = data.webhookId || data; const webhook_id = data.webhookId || data;
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
@@ -687,7 +722,6 @@ export default class MemoryClient {
} }
async feedback(data: FeedbackPayload): Promise<{ message: string }> { async feedback(data: FeedbackPayload): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(data || {}); const payloadKeys = Object.keys(data || {});
this._captureEvent("feedback", [payloadKeys]); this._captureEvent("feedback", [payloadKeys]);
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
@@ -704,7 +738,6 @@ export default class MemoryClient {
async createMemoryExport( async createMemoryExport(
data: CreateMemoryExportPayload, data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> { ): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_memory_export", []); this._captureEvent("create_memory_export", []);
if (!data.filters || !data.schema) { if (!data.filters || !data.schema) {
@@ -734,7 +767,6 @@ export default class MemoryClient {
async getMemoryExport( async getMemoryExport(
data: GetMemoryExportPayload, data: GetMemoryExportPayload,
): Promise<{ message: string; id: string }> { ): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_memory_export", []); this._captureEvent("get_memory_export", []);
if (!data.memoryExportId && !data.filters) { if (!data.memoryExportId && !data.filters) {
@@ -98,16 +98,23 @@ describe("MemoryClient - deleteUser() (deprecated)", () => {
describe("MemoryClient - deleteUsers()", () => { describe("MemoryClient - deleteUsers()", () => {
test("URL-encodes entity path segments", async () => { test("URL-encodes entity path segments", async () => {
setupMockFetch(); const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/org%2Fteam%3Factive%23frag/", {
status: 200,
body: { message: "Entity deleted successfully!" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY }); const client = new MemoryClient({ apiKey: TEST_API_KEY });
client.telemetryId = "test@example.com";
const deleteMock = jest.fn().mockResolvedValue({});
client.client = { delete: deleteMock };
await client.deleteUsers({ userId: "org/team?active#frag" }); await client.deleteUsers({ userId: "org/team?active#frag" });
expect(deleteMock).toHaveBeenCalledWith( expect(
"/v2/entities/user/org%2Fteam%3Factive%23frag/", findFetchCall(
); mock,
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
"DELETE",
),
).toBeDefined();
}); });
}); });