@xeonr/upload-pool-sdk 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,21 +6,64 @@
6
6
  * as the in-band `updateToken` field on each request.
7
7
  * - IntegrationQueueService — accept / complete / report-error.
8
8
  * Auth: the pool token, passed as `queueToken` on each request.
9
+ *
10
+ * Each request is wrapped in a logging interceptor that emits one
11
+ * `rpc.request` line on dispatch and one of `rpc.response` /
12
+ * `rpc.error` on completion. Latency and connect-error codes are
13
+ * captured so failed RPCs are diagnosable from worker logs alone.
9
14
  */
10
15
  import { createConnectTransport } from "@connectrpc/connect-node";
11
- import { createClient, type Client } from "@connectrpc/connect";
16
+ import {
17
+ createClient,
18
+ type Client,
19
+ type Interceptor,
20
+ ConnectError,
21
+ Code,
22
+ } from "@connectrpc/connect";
12
23
  import { InternalUploadsService } from "./protocol/uplim/api/v1/uploads_pb.js";
13
24
  import { IntegrationQueueService } from "./protocol/uplim/workflow/v1/integration_queue_pb.js";
25
+ import type { Logger } from "./logger.js";
14
26
 
15
27
  export interface RpcClients {
16
28
  internalUploads: Client<typeof InternalUploadsService>;
17
29
  integrationQueue: Client<typeof IntegrationQueueService>;
18
30
  }
19
31
 
20
- export function createRpcClients(endpoint: string): RpcClients {
32
+ function loggingInterceptor(logger: Logger): Interceptor {
33
+ return (next) => async (req) => {
34
+ const startedAt = Date.now();
35
+ const method = `${req.service.typeName}/${req.method.name}`;
36
+ logger.debug("rpc.request", { method });
37
+ try {
38
+ const res = await next(req);
39
+ logger.debug("rpc.response", {
40
+ method,
41
+ durationMs: Date.now() - startedAt,
42
+ });
43
+ return res;
44
+ } catch (err) {
45
+ const code =
46
+ err instanceof ConnectError ? Code[err.code] : undefined;
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ logger.error("rpc.error", {
49
+ method,
50
+ code,
51
+ message,
52
+ durationMs: Date.now() - startedAt,
53
+ });
54
+ throw err;
55
+ }
56
+ };
57
+ }
58
+
59
+ export function createRpcClients(
60
+ endpoint: string,
61
+ logger: Logger,
62
+ ): RpcClients {
21
63
  const transport = createConnectTransport({
22
64
  baseUrl: endpoint,
23
65
  httpVersion: "1.1",
66
+ interceptors: [loggingInterceptor(logger.child({ component: "rpc" }))],
24
67
  });
25
68
  return {
26
69
  internalUploads: createClient(InternalUploadsService, transport),
package/src/sse-client.ts CHANGED
@@ -5,14 +5,21 @@
5
5
  *
6
6
  * Reconnect strategy: exponential backoff (1s, 2s, 4s, 8s, capped at 30s).
7
7
  * On `job:dispatch` events the SDK invokes the registered onJob callback.
8
+ *
9
+ * All lifecycle transitions emit structured logs via the injected
10
+ * `Logger` so connection failures are visible in the worker's k8s logs
11
+ * (otherwise a worker that can't connect at all looks identical to one
12
+ * that's idle).
8
13
  */
9
14
  import { EventSource } from "eventsource";
15
+ import type { Logger } from "./logger.js";
10
16
 
11
17
  export interface SseClientConfig {
12
18
  endpoint: string;
13
19
  token: string;
14
20
  workerId: string;
15
21
  capabilities: string[];
22
+ logger: Logger;
16
23
  onConnected: () => void;
17
24
  onDisconnected: (reason: string) => void;
18
25
  onJobDispatch: (payload: unknown) => void;
@@ -25,6 +32,8 @@ export class SseClient {
25
32
  private es: EventSource | null = null;
26
33
  private stopped = false;
27
34
  private reconnectDelay = RECONNECT_INITIAL_MS;
35
+ private connectAttempt = 0;
36
+ private connectedAt: number | null = null;
28
37
 
29
38
  constructor(private readonly config: SseClientConfig) {}
30
39
 
@@ -39,11 +48,13 @@ export class SseClient {
39
48
  this.es.close();
40
49
  this.es = null;
41
50
  }
51
+ this.config.logger.info("sse.stopped");
42
52
  }
43
53
 
44
54
  private connect(): void {
45
55
  if (this.stopped) return;
46
56
 
57
+ this.connectAttempt++;
47
58
  const url = new URL(`${this.config.endpoint}/queue/connect`);
48
59
  url.searchParams.set("queueToken", this.config.token);
49
60
  url.searchParams.set("workerId", this.config.workerId);
@@ -51,31 +62,84 @@ export class SseClient {
51
62
  url.searchParams.set("capabilities", this.config.capabilities.join(","));
52
63
  }
53
64
 
65
+ // Strip the token from the logged URL — the queueToken param is the
66
+ // pool secret, no reason to bake it into telemetry.
67
+ const loggedUrl = new URL(url.toString());
68
+ loggedUrl.searchParams.set("queueToken", "[redacted]");
69
+
70
+ this.config.logger.info("sse.connect.start", {
71
+ url: loggedUrl.toString(),
72
+ attempt: this.connectAttempt,
73
+ capabilities: this.config.capabilities,
74
+ });
75
+
54
76
  this.es = new EventSource(url.toString());
55
77
 
78
+ this.es.addEventListener("open", () => {
79
+ this.config.logger.debug("sse.open");
80
+ });
81
+
56
82
  this.es.addEventListener("connected", () => {
57
83
  this.reconnectDelay = RECONNECT_INITIAL_MS;
84
+ this.connectedAt = Date.now();
85
+ this.config.logger.info("sse.connect.opened", {
86
+ attempt: this.connectAttempt,
87
+ });
58
88
  this.config.onConnected();
59
89
  });
60
90
 
61
91
  this.es.addEventListener("job:dispatch", (event) => {
62
92
  try {
63
93
  const data = JSON.parse((event as MessageEvent).data);
94
+ this.config.logger.debug("sse.event.job_dispatch", {
95
+ jobId: (data as { jobId?: string }).jobId,
96
+ });
64
97
  this.config.onJobDispatch(data);
65
98
  } catch (err) {
66
- this.config.onDisconnected(`bad job payload: ${(err as Error).message}`);
99
+ const reason = `bad job payload: ${(err as Error).message}`;
100
+ this.config.logger.error("sse.event.parse_error", {
101
+ err,
102
+ rawData: (event as MessageEvent).data,
103
+ });
104
+ this.config.onDisconnected(reason);
67
105
  }
68
106
  });
69
107
 
70
- // heartbeat events are no-ops receipt alone keeps the connection alive.
108
+ // heartbeat events are no-ops at the dispatch layer receipt alone
109
+ // keeps the connection alive. We log at debug so noisy keep-alive
110
+ // traffic doesn't drown the info stream.
111
+ this.es.addEventListener("heartbeat", () => {
112
+ this.config.logger.debug("sse.event.heartbeat");
113
+ });
71
114
 
72
- this.es.onerror = () => {
115
+ this.es.onerror = (err: unknown) => {
73
116
  if (this.stopped) return;
74
- this.config.onDisconnected("sse error");
117
+ const errMessage =
118
+ (err as { message?: string; type?: string; status?: number })?.message ??
119
+ (err as { type?: string })?.type ??
120
+ "unknown";
121
+ const status = (err as { status?: number; code?: number })?.status ??
122
+ (err as { code?: number })?.code;
123
+ const wasConnected = this.connectedAt !== null;
124
+ const uptimeMs = wasConnected ? Date.now() - this.connectedAt! : null;
125
+
126
+ this.config.logger.warn("sse.disconnected", {
127
+ message: errMessage,
128
+ status,
129
+ wasConnected,
130
+ uptimeMs,
131
+ attempt: this.connectAttempt,
132
+ nextReconnectMs: this.reconnectDelay,
133
+ });
134
+
135
+ this.config.onDisconnected(`sse error: ${errMessage}`);
136
+ this.connectedAt = null;
137
+
75
138
  if (this.es) {
76
139
  this.es.close();
77
140
  this.es = null;
78
141
  }
142
+
79
143
  setTimeout(() => this.connect(), this.reconnectDelay);
80
144
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
81
145
  };
package/src/types.ts CHANGED
@@ -25,8 +25,21 @@ export interface PoolConfig {
25
25
  handlers: Record<string, JobHandler>;
26
26
  /** Fallback handler if URN has no specific entry in `handlers`. Optional. */
27
27
  onUnhandled?: JobHandler;
28
- /** Lifecycle / error hook. Defaults to console-based logging. */
28
+ /**
29
+ * Lifecycle / error hook. Receives every handler exception alongside
30
+ * the job context. The SDK also logs every failure via `logger` —
31
+ * `onError` is for hooks that need to fan out to external systems
32
+ * (sentry, pagerduty). Optional.
33
+ */
29
34
  onError?: (err: Error, ctx?: JobContext) => void;
35
+ /**
36
+ * Structured logger. Defaults to a JSON-line logger that writes to
37
+ * stdout (`info`/`debug`) and stderr (`warn`/`error`), honoring
38
+ * `UPL_LOG_LEVEL`. Pass `noopLogger` from this package for silence,
39
+ * or supply your own implementing the `Logger` interface to bridge
40
+ * to pino/winston.
41
+ */
42
+ logger?: import("./logger.js").Logger;
30
43
  /** Max concurrent in-flight jobs. Defaults to 1. */
31
44
  concurrency?: number;
32
45
  }