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;
}
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 {
apiKey: string;
host: string;
@@ -91,6 +101,7 @@ export default class MemoryClient {
headers: Record<string, string>;
client: any;
telemetryId: string;
private initialized: Promise<void>;
_validateApiKey(): any {
if (!this.apiKey) {
@@ -122,11 +133,39 @@ export default class MemoryClient {
});
this._validateApiKey();
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 {
await this.ping();
@@ -148,6 +187,12 @@ export default class MemoryClient {
stack: error?.stack || "No stack trace",
});
}
return {
telemetryId: this.telemetryId,
organizationId: this.organizationId,
projectId: this.projectId,
};
}
private async _maybeAliasAnonToEmail(): Promise<void> {
@@ -175,13 +220,18 @@ export default class MemoryClient {
}
private _captureEvent(methodName: string, args: any[]) {
captureClientEvent(methodName, this, {
success: true,
args_count: args.length,
keys: args.length > 0 ? args[0] : [],
}).catch((error: any) => {
console.error("Failed to capture event:", error);
});
// Deferred until ping() has resolved telemetryId, off the request path.
this.initialized
.then(() =>
captureClientEvent(methodName, this, {
success: true,
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> {
@@ -262,8 +312,6 @@ export default class MemoryClient {
throw new Error("Cannot process an empty messages payload.");
}
if (this.telemetryId === "") await this.ping();
const payload = this._preparePayload(messages, options);
const payloadKeys = Object.keys(payload);
this._captureEvent("add", [payloadKeys]);
@@ -304,7 +352,6 @@ export default class MemoryClient {
);
}
if (this.telemetryId === "") await this.ping();
const payload: Record<string, any> = {};
if (text !== undefined) payload.text = text;
if (metadata !== undefined) payload.metadata = metadata;
@@ -326,7 +373,6 @@ export default class MemoryClient {
}
async get(memoryId: string): Promise<Memory> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get", []);
return this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
@@ -340,7 +386,6 @@ export default class MemoryClient {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "getAll");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("get_all", [payloadKeys]);
const { page, pageSize, filters, ...rest } = options ?? {};
@@ -369,7 +414,6 @@ export default class MemoryClient {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "search");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("search", [payloadKeys]);
const { filters, ...rest } = options ?? {};
@@ -395,7 +439,6 @@ export default class MemoryClient {
memoryId: string,
options: DeleteMemoryOptions = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete", [Object.keys(options || {})]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
// @ts-ignore
@@ -412,7 +455,6 @@ export default class MemoryClient {
async deleteAll(
options: DeleteAllMemoryOptions = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("delete_all", [payloadKeys]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
@@ -429,7 +471,6 @@ export default class MemoryClient {
}
async history(memoryId: string): Promise<Array<MemoryHistory>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("history", []);
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`,
@@ -444,7 +485,6 @@ export default class MemoryClient {
page?: number;
pageSize?: number;
}): Promise<AllUsers> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("users", []);
let url = `${this.host}/v1/entities/`;
const params: string[] = [];
@@ -464,7 +504,6 @@ export default class MemoryClient {
entity_id: number;
entity_type: string;
}): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_user", []);
if (!data.entity_type) {
data.entity_type = "user";
@@ -487,8 +526,6 @@ export default class MemoryClient {
runId?: string;
} = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
let to_delete: Array<{ type: string; name: string }> = [];
const { userId, agentId, appId, runId } = params;
@@ -514,8 +551,14 @@ export default class MemoryClient {
for (const entity of to_delete) {
try {
await this.client.delete(
`/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
// fetch() reuses the pooled connection; axios here defaulted to
// 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) {
throw new APIError(
@@ -537,7 +580,6 @@ export default class MemoryClient {
}
async batchUpdate(memories: Array<MemoryUpdateBody>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_update", []);
const memoriesBody = memories.map((memory) => ({
memory_id: memory.memoryId,
@@ -555,7 +597,6 @@ export default class MemoryClient {
}
async batchDelete(memories: Array<string>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_delete", []);
const memoriesBody = memories.map((memory) => ({
memory_id: memory,
@@ -572,7 +613,6 @@ export default class MemoryClient {
}
async getProject(options: ProjectOptions): Promise<ProjectResponse> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("get_project", [payloadKeys]);
const { fields } = options;
@@ -598,7 +638,6 @@ export default class MemoryClient {
async updateProject(
prompts: PromptUpdatePayload,
): Promise<Record<string, any>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_project", []);
if (!(this.organizationId && this.projectId)) {
throw new Error(
@@ -619,7 +658,6 @@ export default class MemoryClient {
// WebHooks
async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_webhooks", []);
const project_id = data?.projectId || this.projectId;
const response = await this._fetchWithErrorHandling(
@@ -632,7 +670,6 @@ export default class MemoryClient {
}
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_webhook", []);
const body = {
name: webhook.name,
@@ -653,7 +690,6 @@ export default class MemoryClient {
async updateWebhook(
webhook: WebhookUpdatePayload,
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_webhook", []);
const body: Record<string, any> = {};
if (webhook.name != null) body.name = webhook.name;
@@ -673,7 +709,6 @@ export default class MemoryClient {
async deleteWebhook(data: {
webhookId: string;
}): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_webhook", []);
const webhook_id = data.webhookId || data;
const response = await this._fetchWithErrorHandling(
@@ -687,7 +722,6 @@ export default class MemoryClient {
}
async feedback(data: FeedbackPayload): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(data || {});
this._captureEvent("feedback", [payloadKeys]);
const response = await this._fetchWithErrorHandling(
@@ -704,7 +738,6 @@ export default class MemoryClient {
async createMemoryExport(
data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_memory_export", []);
if (!data.filters || !data.schema) {
@@ -734,7 +767,6 @@ export default class MemoryClient {
async getMemoryExport(
data: GetMemoryExportPayload,
): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_memory_export", []);
if (!data.memoryExportId && !data.filters) {
@@ -98,16 +98,23 @@ describe("MemoryClient - deleteUser() (deprecated)", () => {
describe("MemoryClient - deleteUsers()", () => {
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 });
client.telemetryId = "test@example.com";
const deleteMock = jest.fn().mockResolvedValue({});
client.client = { delete: deleteMock };
await client.deleteUsers({ userId: "org/team?active#frag" });
expect(deleteMock).toHaveBeenCalledWith(
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
);
expect(
findFetchCall(
mock,
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
"DELETE",
),
).toBeDefined();
});
});