fix(ts-sdk): take /v1/ping/ off the request critical path (#6788)
This commit is contained in:
+67
-35
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user