@openfn/ws-worker 1.12.0 → 1.13.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # ws-worker
2
2
 
3
+ ## 1.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ce5022a: Added sentry notifications for server and websocket errors
8
+
9
+ ### Patch Changes
10
+
11
+ - 0a176aa: Ignore empty log lines (don't send them to lightning)
12
+ - Updated dependencies [0a176aa]
13
+ - @openfn/logger@1.0.5
14
+ - @openfn/engine-multi@1.6.2
15
+ - @openfn/lexicon@1.2.0
16
+ - @openfn/runtime@1.6.4
17
+
18
+ ## 1.12.1
19
+
20
+ ### Patch Changes
21
+
22
+ - e2f1197: Better logging on credential errors
23
+ - Updated dependencies [e2f1197]
24
+ - @openfn/engine-multi@1.6.1
25
+
3
26
  ## 1.12.0
4
27
 
5
28
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -53,6 +53,7 @@ type WorkerRunOptions = ExecuteOptions & {
53
53
  };
54
54
 
55
55
  type Context = {
56
+ id: string;
56
57
  channel: Channel;
57
58
  state: RunState;
58
59
  logger: Logger;
@@ -73,6 +74,8 @@ type ServerOptions = {
73
74
  min?: number;
74
75
  max?: number;
75
76
  };
77
+ sentryDsn?: string;
78
+ sentryEnv?: string;
76
79
  socketTimeoutSeconds?: number;
77
80
  payloadLimitMb?: number;
78
81
  collectionsVersion?: string;
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { EventEmitter as EventEmitter2 } from "node:events";
3
3
  import { promisify } from "node:util";
4
4
  import { exec as _exec } from "node:child_process";
5
+ import * as Sentry5 from "@sentry/node";
5
6
  import Koa from "koa";
6
7
  import bodyParser from "koa-bodyparser";
7
8
  import koaLogger from "koa-logger";
@@ -210,6 +211,9 @@ var startWorkloop = (app, logger, minBackoff, maxBackoff, maxWorkers) => {
210
211
  };
211
212
  var workloop_default = startWorkloop;
212
213
 
214
+ // src/api/execute.ts
215
+ import * as Sentry2 from "@sentry/node";
216
+
213
217
  // src/util/convert-lightning-plan.ts
214
218
  import crypto2 from "node:crypto";
215
219
  import path from "node:path";
@@ -387,26 +391,6 @@ var convert_lightning_plan_default = (run, options = {}) => {
387
391
  };
388
392
  };
389
393
 
390
- // src/util/get-with-reply.ts
391
- var get_with_reply_default = (channel, event, payload) => new Promise((resolve, reject) => {
392
- channel.push(event, payload).receive("ok", (evt) => {
393
- resolve(evt);
394
- }).receive("error", (e) => {
395
- reject(e);
396
- }).receive("timeout", (e) => {
397
- reject(e);
398
- });
399
- });
400
-
401
- // src/util/stringify.ts
402
- import stringify from "fast-safe-stringify";
403
- var stringify_default = (obj) => stringify(obj, (_key, value) => {
404
- if (value instanceof Uint8Array) {
405
- return Array.from(value);
406
- }
407
- return value;
408
- });
409
-
410
394
  // src/util/create-run-state.ts
411
395
  var create_run_state_default = (plan, input) => {
412
396
  const state = {
@@ -440,6 +424,67 @@ var create_run_state_default = (plan, input) => {
440
424
  return state;
441
425
  };
442
426
 
427
+ // src/util/send-event.ts
428
+ import * as Sentry from "@sentry/node";
429
+
430
+ // src/errors.ts
431
+ var LightningSocketError = class extends Error {
432
+ constructor(event, message) {
433
+ super(`[${event}] ${message}`);
434
+ this.name = "LightningSocketError";
435
+ this.event = "";
436
+ this.rejectMessage = "";
437
+ this.event = event;
438
+ this.rejectMessage = message;
439
+ }
440
+ };
441
+ var LightningTimeoutError = class extends Error {
442
+ constructor(event) {
443
+ super(`[${event}] timeout`);
444
+ this.name = "LightningTimeoutError";
445
+ }
446
+ };
447
+
448
+ // src/util/send-event.ts
449
+ var sendEvent = (context, event, payload) => {
450
+ const { channel, logger, id: runId = "<unknown run>" } = context;
451
+ return new Promise((resolve, reject) => {
452
+ const report = (error) => {
453
+ logger.error(`${runId} :: ${event} :: ERR: ${error.message || error}`);
454
+ const context2 = {
455
+ run_id: runId,
456
+ event
457
+ };
458
+ const extras = {};
459
+ if (error.rejectMessage) {
460
+ extras.rejection_reason = error.rejectMessage;
461
+ }
462
+ Sentry.captureException(error, (scope) => {
463
+ scope.setContext("run", context2);
464
+ scope.setExtras(extras);
465
+ return scope;
466
+ });
467
+ error.reportedToSentry = true;
468
+ reject(error);
469
+ };
470
+ channel.push(event, payload).receive("error", (message) => {
471
+ report(new LightningSocketError(event, message));
472
+ }).receive("timeout", () => {
473
+ report(new LightningTimeoutError(event));
474
+ }).receive("ok", resolve);
475
+ });
476
+ };
477
+ var send_event_default = sendEvent;
478
+
479
+ // src/util/stringify.ts
480
+ import stringify from "fast-safe-stringify";
481
+ var stringify_default = (obj) => stringify(obj, (_key, value) => {
482
+ if (value instanceof Uint8Array) {
483
+ return Array.from(value);
484
+ }
485
+ return value;
486
+ });
487
+
443
488
  // src/util/throttle.ts
444
489
  var createThrottler = () => {
445
490
  const q = [];
@@ -525,7 +570,7 @@ function getVersion() {
525
570
 
526
571
  // src/events/run-start.ts
527
572
  async function onRunStart(context, event) {
528
- const { channel, state, options = {} } = context;
573
+ const { state, options = {} } = context;
529
574
  const time = (timestamp() - BigInt(1e7)).toString();
530
575
  const versionLogContext = {
531
576
  ...context,
@@ -538,7 +583,7 @@ async function onRunStart(context, event) {
538
583
  worker: await getVersion(),
539
584
  ...event.versions
540
585
  };
541
- await sendEvent(channel, RUN_START, {
586
+ await sendEvent(context, RUN_START, {
542
587
  versions,
543
588
  /// use the engine time in run start
544
589
  timestamp: timeInMicroseconds(event.time)
@@ -600,7 +645,7 @@ var calculateRunExitReason = (state) => {
600
645
 
601
646
  // src/events/step-complete.ts
602
647
  async function onStepComplete(context, event, error) {
603
- const { channel, state, options } = context;
648
+ const { state, options } = context;
604
649
  const dataclipId = crypto3.randomUUID();
605
650
  const step_id = state.activeStep;
606
651
  const job_id = state.activeJob;
@@ -645,13 +690,13 @@ async function onStepComplete(context, event, error) {
645
690
  const reason = calculateJobExitReason(job_id, event.state, error);
646
691
  state.reasons[job_id] = reason;
647
692
  Object.assign(evt, reason);
648
- return sendEvent(channel, STEP_COMPLETE, evt);
693
+ return sendEvent(context, STEP_COMPLETE, evt);
649
694
  }
650
695
 
651
696
  // src/events/step-start.ts
652
697
  import crypto4 from "node:crypto";
653
698
  async function onStepStart(context, event) {
654
- const { channel, state } = context;
699
+ const { state } = context;
655
700
  state.activeStep = crypto4.randomUUID();
656
701
  state.activeJob = event.jobId;
657
702
  const input_dataclip_id = state.inputDataclips[event.jobId];
@@ -663,7 +708,7 @@ async function onStepStart(context, event) {
663
708
  if (!state.withheldDataclips[input_dataclip_id]) {
664
709
  evt.input_dataclip_id = input_dataclip_id;
665
710
  }
666
- await sendEvent(channel, STEP_START, evt);
711
+ await sendEvent(context, STEP_START, evt);
667
712
  }
668
713
 
669
714
  // src/util/log-final-reason.ts
@@ -685,12 +730,12 @@ ${reason.error_type}: ${reason.error_message || "unknown"}`;
685
730
 
686
731
  // src/events/run-complete.ts
687
732
  async function onWorkflowComplete(context, event) {
688
- const { state, channel, onFinish, logger } = context;
733
+ const { state, onFinish, logger } = context;
689
734
  const result = state.dataclips[state.lastDataclipId];
690
735
  const reason = calculateRunExitReason(state);
691
736
  await log_final_reason_default(context, reason);
692
737
  try {
693
- await sendEvent(channel, RUN_COMPLETE, {
738
+ await sendEvent(context, RUN_COMPLETE, {
694
739
  final_dataclip_id: state.lastDataclipId,
695
740
  timestamp: timeInMicroseconds(event.time),
696
741
  ...reason
@@ -706,14 +751,14 @@ async function onWorkflowComplete(context, event) {
706
751
 
707
752
  // src/events/run-error.ts
708
753
  async function onRunError(context, event) {
709
- const { state, channel, logger, onFinish } = context;
754
+ const { state, logger, onFinish } = context;
710
755
  try {
711
756
  const reason = calculateJobExitReason("", { data: {} }, event);
712
757
  if (state.activeJob) {
713
758
  await onJobError(context, { error: event });
714
759
  }
715
760
  await log_final_reason_default(context, reason);
716
- await sendEvent(channel, RUN_COMPLETE, {
761
+ await sendEvent(context, RUN_COMPLETE, {
717
762
  final_dataclip_id: state.lastDataclipId,
718
763
  ...reason
719
764
  });
@@ -739,6 +784,7 @@ function execute(channel, engine, logger, plan, input, options = {}, onFinish =
739
784
  logger.info("executing ", plan.id);
740
785
  const state = create_run_state_default(plan, input);
741
786
  const context = {
787
+ id: plan.id,
742
788
  channel,
743
789
  state,
744
790
  logger,
@@ -746,76 +792,104 @@ function execute(channel, engine, logger, plan, input, options = {}, onFinish =
746
792
  options,
747
793
  onFinish
748
794
  };
749
- const throttle = throttle_default();
750
- const addEvent = (eventName, handler) => {
751
- const wrappedFn = async (event) => {
752
- const lightningEvent = eventMap[eventName] ?? eventName;
753
- try {
754
- await handler(context, event);
755
- logger.info(`${plan.id} :: ${lightningEvent} :: OK`);
756
- } catch (e) {
757
- logger.error(
758
- `${plan.id} :: ${lightningEvent} :: ERR: ${e.message || e.toString()}`
759
- );
760
- logger.error(e);
795
+ Sentry2.withIsolationScope(async () => {
796
+ Sentry2.addBreadcrumb({
797
+ category: "run",
798
+ message: "Executing run: loading metadata",
799
+ level: "info",
800
+ data: {
801
+ runId: plan.id
761
802
  }
803
+ });
804
+ const throttle = throttle_default();
805
+ const addEvent = (eventName, handler) => {
806
+ const wrappedFn = async (event) => {
807
+ if (eventName !== "workflow-log") {
808
+ Sentry2.addBreadcrumb({
809
+ category: "event",
810
+ message: eventName,
811
+ level: "info"
812
+ });
813
+ }
814
+ const lightningEvent = eventMap[eventName] ?? eventName;
815
+ try {
816
+ await handler(context, event);
817
+ logger.info(`${plan.id} :: ${lightningEvent} :: OK`);
818
+ } catch (e) {
819
+ if (!e.reportedToSentry) {
820
+ Sentry2.captureException(e);
821
+ logger.error(e);
822
+ }
823
+ }
824
+ };
825
+ return {
826
+ [eventName]: wrappedFn
827
+ };
762
828
  };
763
- return {
764
- [eventName]: wrappedFn
829
+ const listeners = Object.assign(
830
+ {},
831
+ addEvent("workflow-start", throttle(onRunStart)),
832
+ addEvent("job-start", throttle(onStepStart)),
833
+ addEvent("job-complete", throttle(onStepComplete)),
834
+ addEvent("job-error", throttle(onJobError)),
835
+ addEvent("workflow-log", throttle(onJobLog)),
836
+ // This will also resolve the promise
837
+ addEvent("workflow-complete", throttle(onWorkflowComplete)),
838
+ addEvent("workflow-error", throttle(onRunError))
839
+ // TODO send autoinstall logs
840
+ );
841
+ engine.listen(plan.id, listeners);
842
+ const resolvers = {
843
+ credential: (id) => loadCredential(context, id)
844
+ // TODO not supported right now
845
+ // dataclip: (id: string) => loadDataclip(channel, id),
765
846
  };
766
- };
767
- const listeners = Object.assign(
768
- {},
769
- addEvent("workflow-start", throttle(onRunStart)),
770
- addEvent("job-start", throttle(onStepStart)),
771
- addEvent("job-complete", throttle(onStepComplete)),
772
- addEvent("job-error", throttle(onJobError)),
773
- addEvent("workflow-log", throttle(onJobLog)),
774
- // This will also resolve the promise
775
- addEvent("workflow-complete", throttle(onWorkflowComplete)),
776
- addEvent("workflow-error", throttle(onRunError))
777
- // TODO send autoinstall logs
778
- );
779
- engine.listen(plan.id, listeners);
780
- const resolvers = {
781
- credential: (id) => loadCredential(channel, id)
782
- // TODO not supported right now
783
- // dataclip: (id: string) => loadDataclip(channel, id),
784
- };
785
- setTimeout(async () => {
786
- let loadedInput = input;
787
- if (typeof input === "string") {
788
- logger.debug("loading dataclip", input);
847
+ setTimeout(async () => {
848
+ let loadedInput = input;
849
+ if (typeof input === "string") {
850
+ logger.debug("loading dataclip", input);
851
+ try {
852
+ loadedInput = await loadDataclip(context, input);
853
+ logger.success("dataclip loaded");
854
+ } catch (e) {
855
+ return onRunError(context, {
856
+ workflowId: plan.id,
857
+ message: `Failed to load dataclip ${input}${e.message ? `: ${e.message}` : ""}`,
858
+ type: "DataClipError",
859
+ severity: "exception"
860
+ });
861
+ }
862
+ }
789
863
  try {
790
- loadedInput = await loadDataclip(channel, input);
791
- logger.success("dataclip loaded");
864
+ Sentry2.addBreadcrumb({
865
+ category: "run",
866
+ message: "run metadata loaded: starting run",
867
+ level: "info",
868
+ data: {
869
+ runId: plan.id
870
+ }
871
+ });
872
+ engine.execute(plan, loadedInput, { resolvers, ...options });
792
873
  } catch (e) {
793
- return onRunError(context, {
874
+ Sentry2.addBreadcrumb({
875
+ category: "run",
876
+ message: "exception in run",
877
+ level: "info",
878
+ data: {
879
+ runId: plan.id
880
+ }
881
+ });
882
+ onRunError(context, {
794
883
  workflowId: plan.id,
795
- message: `Failed to load dataclip ${input}${e.message ? `: ${e.message}` : ""}`,
796
- type: "DataClipError",
797
- severity: "exception"
884
+ message: e.message,
885
+ type: e.type,
886
+ severity: e.severity
798
887
  });
799
888
  }
800
- }
801
- try {
802
- engine.execute(plan, loadedInput, { resolvers, ...options });
803
- } catch (e) {
804
- onRunError(context, {
805
- workflowId: plan.id,
806
- message: e.message,
807
- type: e.type,
808
- severity: e.severity
809
- });
810
- }
889
+ });
811
890
  });
812
891
  return context;
813
892
  }
814
- var sendEvent = (channel, event, payload) => new Promise((resolve, reject) => {
815
- channel.push(event, payload).receive("error", reject).receive("timeout", () => {
816
- reject(new Error("timeout"));
817
- }).receive("ok", resolve);
818
- });
819
893
  function onJobError(context, event) {
820
894
  const { state, error, jobId } = event;
821
895
  if (state?.errors?.[jobId]?.message === error.message) {
@@ -824,7 +898,8 @@ function onJobError(context, event) {
824
898
  return onStepComplete(context, event, event.error);
825
899
  }
826
900
  }
827
- function onJobLog({ channel, state, options }, event) {
901
+ function onJobLog(context, event) {
902
+ const { state, options } = context;
828
903
  let message = event.message;
829
904
  if (event.redacted) {
830
905
  message = [
@@ -844,17 +919,17 @@ function onJobLog({ channel, state, options }, event) {
844
919
  if (state.activeStep) {
845
920
  log.step_id = state.activeStep;
846
921
  }
847
- return sendEvent(channel, RUN_LOG, log);
922
+ return sendEvent(context, RUN_LOG, log);
848
923
  }
849
- async function loadDataclip(channel, stateId) {
850
- const result = await get_with_reply_default(channel, GET_DATACLIP, {
924
+ async function loadDataclip(context, stateId) {
925
+ const result = await sendEvent(context, GET_DATACLIP, {
851
926
  id: stateId
852
927
  });
853
928
  const str = enc.decode(new Uint8Array(result));
854
929
  return JSON.parse(str);
855
930
  }
856
- async function loadCredential(channel, credentialId) {
857
- return get_with_reply_default(channel, GET_CREDENTIAL, { id: credentialId });
931
+ async function loadCredential(context, credentialId) {
932
+ return sendEvent(context, GET_CREDENTIAL, { id: credentialId });
858
933
  }
859
934
 
860
935
  // src/middleware/healthcheck.ts
@@ -863,6 +938,7 @@ var healthcheck_default = (ctx) => {
863
938
  };
864
939
 
865
940
  // src/channels/run.ts
941
+ import * as Sentry3 from "@sentry/node";
866
942
  var joinRunChannel = (socket, token, runId, logger) => {
867
943
  return new Promise((resolve, reject) => {
868
944
  let didReceiveOk = false;
@@ -873,13 +949,18 @@ var joinRunChannel = (socket, token, runId, logger) => {
873
949
  if (!didReceiveOk) {
874
950
  didReceiveOk = true;
875
951
  logger.success(`connected to ${channelName}`, e);
876
- const run = await get_with_reply_default(channel, GET_PLAN);
952
+ const run = await send_event_default(
953
+ { channel, logger, id: runId },
954
+ GET_PLAN
955
+ );
877
956
  resolve({ channel, run });
878
957
  }
879
958
  }).receive("error", (err) => {
959
+ Sentry3.captureException(err);
880
960
  logger.error(`error connecting to ${channelName}`, err);
881
961
  reject(err);
882
962
  }).receive("timeout", (err) => {
963
+ Sentry3.captureException(err);
883
964
  logger.error(`Timeout for ${channelName}`, err);
884
965
  reject(err);
885
966
  });
@@ -896,6 +977,7 @@ var run_default = joinRunChannel;
896
977
 
897
978
  // src/channels/worker-queue.ts
898
979
  import EventEmitter from "node:events";
980
+ import * as Sentry4 from "@sentry/node";
899
981
  import { Socket as PhxSocket } from "phoenix";
900
982
  import { WebSocket } from "ws";
901
983
  import { API_VERSION } from "@openfn/lexicon/lightning";
@@ -924,7 +1006,17 @@ var worker_token_default = generateWorkerToken;
924
1006
  // src/channels/worker-queue.ts
925
1007
  var connectToWorkerQueue = (endpoint, serverId, secret, timeout = 10, logger, SocketConstructor = PhxSocket) => {
926
1008
  const events = new EventEmitter();
1009
+ Sentry4.addBreadcrumb({
1010
+ category: "lifecycle",
1011
+ message: "Connecting to worker queue",
1012
+ level: "info"
1013
+ });
927
1014
  worker_token_default(secret, serverId, logger).then(async (token) => {
1015
+ Sentry4.addBreadcrumb({
1016
+ category: "lifecycle",
1017
+ message: "Worker token generated",
1018
+ level: "info"
1019
+ });
928
1020
  const params = {
929
1021
  token,
930
1022
  api_version: API_VERSION,
@@ -933,11 +1025,19 @@ var connectToWorkerQueue = (endpoint, serverId, secret, timeout = 10, logger, So
933
1025
  const socket = new SocketConstructor(endpoint, {
934
1026
  params,
935
1027
  transport: WebSocket,
936
- timeout: timeout * 1e3
1028
+ timeout: timeout * 1e3,
1029
+ reconnectAfterMs: (tries) => Math.max(tries * 1e3)
937
1030
  });
938
1031
  let didOpen = false;
1032
+ let shouldReportConnectionError = true;
939
1033
  socket.onOpen(() => {
1034
+ Sentry4.addBreadcrumb({
1035
+ category: "lifecycle",
1036
+ message: "Web socket connected",
1037
+ level: "info"
1038
+ });
940
1039
  didOpen = true;
1040
+ shouldReportConnectionError = true;
941
1041
  const channel = socket.channel("worker:queue");
942
1042
  channel.onMessage = (ev, load) => {
943
1043
  events.emit("message", ev, load);
@@ -957,6 +1057,16 @@ var connectToWorkerQueue = (endpoint, serverId, secret, timeout = 10, logger, So
957
1057
  events.emit("disconnect");
958
1058
  });
959
1059
  socket.onError((e) => {
1060
+ Sentry4.addBreadcrumb({
1061
+ category: "lifecycle",
1062
+ message: "Error in web socket connection",
1063
+ level: "info"
1064
+ });
1065
+ if (shouldReportConnectionError) {
1066
+ logger.debug("Reporting connection error to sentry");
1067
+ shouldReportConnectionError = false;
1068
+ Sentry4.captureException(e);
1069
+ }
960
1070
  if (!didOpen) {
961
1071
  events.emit("error", e.message);
962
1072
  didOpen = false;
@@ -1061,6 +1171,13 @@ function createServer(engine, options = {}) {
1061
1171
  const router = new Router();
1062
1172
  app.events = new EventEmitter2();
1063
1173
  app.engine = engine;
1174
+ if (options.sentryDsn) {
1175
+ Sentry5.init({
1176
+ environment: options.sentryEnv,
1177
+ dsn: options.sentryDsn
1178
+ });
1179
+ Sentry5.setupKoaErrorHandler(app);
1180
+ }
1064
1181
  app.use(bodyParser());
1065
1182
  app.use(
1066
1183
  koaLogger((str, _args) => {
package/dist/start.js CHANGED
@@ -142,6 +142,7 @@ var runtime_engine_default = createMock;
142
142
  import { EventEmitter as EventEmitter3 } from "node:events";
143
143
  import { promisify } from "node:util";
144
144
  import { exec as _exec } from "node:child_process";
145
+ import * as Sentry5 from "@sentry/node";
145
146
  import Koa from "koa";
146
147
  import bodyParser from "koa-bodyparser";
147
148
  import koaLogger from "koa-logger";
@@ -350,6 +351,9 @@ var startWorkloop = (app, logger2, minBackoff2, maxBackoff2, maxWorkers) => {
350
351
  };
351
352
  var workloop_default = startWorkloop;
352
353
 
354
+ // src/api/execute.ts
355
+ import * as Sentry2 from "@sentry/node";
356
+
353
357
  // src/util/convert-lightning-plan.ts
354
358
  import crypto3 from "node:crypto";
355
359
  import path from "node:path";
@@ -527,26 +531,6 @@ var convert_lightning_plan_default = (run2, options = {}) => {
527
531
  };
528
532
  };
529
533
 
530
- // src/util/get-with-reply.ts
531
- var get_with_reply_default = (channel, event, payload) => new Promise((resolve5, reject) => {
532
- channel.push(event, payload).receive("ok", (evt) => {
533
- resolve5(evt);
534
- }).receive("error", (e) => {
535
- reject(e);
536
- }).receive("timeout", (e) => {
537
- reject(e);
538
- });
539
- });
540
-
541
- // src/util/stringify.ts
542
- import stringify from "fast-safe-stringify";
543
- var stringify_default = (obj) => stringify(obj, (_key, value) => {
544
- if (value instanceof Uint8Array) {
545
- return Array.from(value);
546
- }
547
- return value;
548
- });
549
-
550
534
  // src/util/create-run-state.ts
551
535
  var create_run_state_default = (plan, input) => {
552
536
  const state = {
@@ -580,6 +564,67 @@ var create_run_state_default = (plan, input) => {
580
564
  return state;
581
565
  };
582
566
 
567
+ // src/util/send-event.ts
568
+ import * as Sentry from "@sentry/node";
569
+
570
+ // src/errors.ts
571
+ var LightningSocketError = class extends Error {
572
+ constructor(event, message) {
573
+ super(`[${event}] ${message}`);
574
+ this.name = "LightningSocketError";
575
+ this.event = "";
576
+ this.rejectMessage = "";
577
+ this.event = event;
578
+ this.rejectMessage = message;
579
+ }
580
+ };
581
+ var LightningTimeoutError = class extends Error {
582
+ constructor(event) {
583
+ super(`[${event}] timeout`);
584
+ this.name = "LightningTimeoutError";
585
+ }
586
+ };
587
+
588
+ // src/util/send-event.ts
589
+ var sendEvent = (context, event, payload) => {
590
+ const { channel, logger: logger2, id: runId = "<unknown run>" } = context;
591
+ return new Promise((resolve5, reject) => {
592
+ const report = (error) => {
593
+ logger2.error(`${runId} :: ${event} :: ERR: ${error.message || error}`);
594
+ const context2 = {
595
+ run_id: runId,
596
+ event
597
+ };
598
+ const extras = {};
599
+ if (error.rejectMessage) {
600
+ extras.rejection_reason = error.rejectMessage;
601
+ }
602
+ Sentry.captureException(error, (scope) => {
603
+ scope.setContext("run", context2);
604
+ scope.setExtras(extras);
605
+ return scope;
606
+ });
607
+ error.reportedToSentry = true;
608
+ reject(error);
609
+ };
610
+ channel.push(event, payload).receive("error", (message) => {
611
+ report(new LightningSocketError(event, message));
612
+ }).receive("timeout", () => {
613
+ report(new LightningTimeoutError(event));
614
+ }).receive("ok", resolve5);
615
+ });
616
+ };
617
+ var send_event_default = sendEvent;
618
+
619
+ // src/util/stringify.ts
620
+ import stringify from "fast-safe-stringify";
621
+ var stringify_default = (obj) => stringify(obj, (_key, value) => {
622
+ if (value instanceof Uint8Array) {
623
+ return Array.from(value);
624
+ }
625
+ return value;
626
+ });
627
+
583
628
  // src/util/throttle.ts
584
629
  var createThrottler = () => {
585
630
  const q = [];
@@ -665,7 +710,7 @@ function getVersion() {
665
710
 
666
711
  // src/events/run-start.ts
667
712
  async function onRunStart(context, event) {
668
- const { channel, state, options = {} } = context;
713
+ const { state, options = {} } = context;
669
714
  const time = (timestamp() - BigInt(1e7)).toString();
670
715
  const versionLogContext = {
671
716
  ...context,
@@ -678,7 +723,7 @@ async function onRunStart(context, event) {
678
723
  worker: await getVersion(),
679
724
  ...event.versions
680
725
  };
681
- await sendEvent(channel, RUN_START, {
726
+ await sendEvent(context, RUN_START, {
682
727
  versions,
683
728
  /// use the engine time in run start
684
729
  timestamp: timeInMicroseconds(event.time)
@@ -740,7 +785,7 @@ var calculateRunExitReason = (state) => {
740
785
 
741
786
  // src/events/step-complete.ts
742
787
  async function onStepComplete(context, event, error) {
743
- const { channel, state, options } = context;
788
+ const { state, options } = context;
744
789
  const dataclipId = crypto4.randomUUID();
745
790
  const step_id = state.activeStep;
746
791
  const job_id = state.activeJob;
@@ -785,13 +830,13 @@ async function onStepComplete(context, event, error) {
785
830
  const reason = calculateJobExitReason(job_id, event.state, error);
786
831
  state.reasons[job_id] = reason;
787
832
  Object.assign(evt, reason);
788
- return sendEvent(channel, STEP_COMPLETE, evt);
833
+ return sendEvent(context, STEP_COMPLETE, evt);
789
834
  }
790
835
 
791
836
  // src/events/step-start.ts
792
837
  import crypto5 from "node:crypto";
793
838
  async function onStepStart(context, event) {
794
- const { channel, state } = context;
839
+ const { state } = context;
795
840
  state.activeStep = crypto5.randomUUID();
796
841
  state.activeJob = event.jobId;
797
842
  const input_dataclip_id = state.inputDataclips[event.jobId];
@@ -803,7 +848,7 @@ async function onStepStart(context, event) {
803
848
  if (!state.withheldDataclips[input_dataclip_id]) {
804
849
  evt.input_dataclip_id = input_dataclip_id;
805
850
  }
806
- await sendEvent(channel, STEP_START, evt);
851
+ await sendEvent(context, STEP_START, evt);
807
852
  }
808
853
 
809
854
  // src/util/log-final-reason.ts
@@ -825,12 +870,12 @@ ${reason.error_type}: ${reason.error_message || "unknown"}`;
825
870
 
826
871
  // src/events/run-complete.ts
827
872
  async function onWorkflowComplete(context, event) {
828
- const { state, channel, onFinish, logger: logger2 } = context;
873
+ const { state, onFinish, logger: logger2 } = context;
829
874
  const result = state.dataclips[state.lastDataclipId];
830
875
  const reason = calculateRunExitReason(state);
831
876
  await log_final_reason_default(context, reason);
832
877
  try {
833
- await sendEvent(channel, RUN_COMPLETE, {
878
+ await sendEvent(context, RUN_COMPLETE, {
834
879
  final_dataclip_id: state.lastDataclipId,
835
880
  timestamp: timeInMicroseconds(event.time),
836
881
  ...reason
@@ -846,14 +891,14 @@ async function onWorkflowComplete(context, event) {
846
891
 
847
892
  // src/events/run-error.ts
848
893
  async function onRunError(context, event) {
849
- const { state, channel, logger: logger2, onFinish } = context;
894
+ const { state, logger: logger2, onFinish } = context;
850
895
  try {
851
896
  const reason = calculateJobExitReason("", { data: {} }, event);
852
897
  if (state.activeJob) {
853
898
  await onJobError(context, { error: event });
854
899
  }
855
900
  await log_final_reason_default(context, reason);
856
- await sendEvent(channel, RUN_COMPLETE, {
901
+ await sendEvent(context, RUN_COMPLETE, {
857
902
  final_dataclip_id: state.lastDataclipId,
858
903
  ...reason
859
904
  });
@@ -879,6 +924,7 @@ function execute(channel, engine, logger2, plan, input, options = {}, onFinish =
879
924
  logger2.info("executing ", plan.id);
880
925
  const state = create_run_state_default(plan, input);
881
926
  const context = {
927
+ id: plan.id,
882
928
  channel,
883
929
  state,
884
930
  logger: logger2,
@@ -886,76 +932,104 @@ function execute(channel, engine, logger2, plan, input, options = {}, onFinish =
886
932
  options,
887
933
  onFinish
888
934
  };
889
- const throttle = throttle_default();
890
- const addEvent = (eventName, handler) => {
891
- const wrappedFn = async (event) => {
892
- const lightningEvent = eventMap[eventName] ?? eventName;
893
- try {
894
- await handler(context, event);
895
- logger2.info(`${plan.id} :: ${lightningEvent} :: OK`);
896
- } catch (e) {
897
- logger2.error(
898
- `${plan.id} :: ${lightningEvent} :: ERR: ${e.message || e.toString()}`
899
- );
900
- logger2.error(e);
935
+ Sentry2.withIsolationScope(async () => {
936
+ Sentry2.addBreadcrumb({
937
+ category: "run",
938
+ message: "Executing run: loading metadata",
939
+ level: "info",
940
+ data: {
941
+ runId: plan.id
901
942
  }
943
+ });
944
+ const throttle = throttle_default();
945
+ const addEvent = (eventName, handler) => {
946
+ const wrappedFn = async (event) => {
947
+ if (eventName !== "workflow-log") {
948
+ Sentry2.addBreadcrumb({
949
+ category: "event",
950
+ message: eventName,
951
+ level: "info"
952
+ });
953
+ }
954
+ const lightningEvent = eventMap[eventName] ?? eventName;
955
+ try {
956
+ await handler(context, event);
957
+ logger2.info(`${plan.id} :: ${lightningEvent} :: OK`);
958
+ } catch (e) {
959
+ if (!e.reportedToSentry) {
960
+ Sentry2.captureException(e);
961
+ logger2.error(e);
962
+ }
963
+ }
964
+ };
965
+ return {
966
+ [eventName]: wrappedFn
967
+ };
902
968
  };
903
- return {
904
- [eventName]: wrappedFn
969
+ const listeners = Object.assign(
970
+ {},
971
+ addEvent("workflow-start", throttle(onRunStart)),
972
+ addEvent("job-start", throttle(onStepStart)),
973
+ addEvent("job-complete", throttle(onStepComplete)),
974
+ addEvent("job-error", throttle(onJobError)),
975
+ addEvent("workflow-log", throttle(onJobLog)),
976
+ // This will also resolve the promise
977
+ addEvent("workflow-complete", throttle(onWorkflowComplete)),
978
+ addEvent("workflow-error", throttle(onRunError))
979
+ // TODO send autoinstall logs
980
+ );
981
+ engine.listen(plan.id, listeners);
982
+ const resolvers = {
983
+ credential: (id) => loadCredential(context, id)
984
+ // TODO not supported right now
985
+ // dataclip: (id: string) => loadDataclip(channel, id),
905
986
  };
906
- };
907
- const listeners = Object.assign(
908
- {},
909
- addEvent("workflow-start", throttle(onRunStart)),
910
- addEvent("job-start", throttle(onStepStart)),
911
- addEvent("job-complete", throttle(onStepComplete)),
912
- addEvent("job-error", throttle(onJobError)),
913
- addEvent("workflow-log", throttle(onJobLog)),
914
- // This will also resolve the promise
915
- addEvent("workflow-complete", throttle(onWorkflowComplete)),
916
- addEvent("workflow-error", throttle(onRunError))
917
- // TODO send autoinstall logs
918
- );
919
- engine.listen(plan.id, listeners);
920
- const resolvers = {
921
- credential: (id) => loadCredential(channel, id)
922
- // TODO not supported right now
923
- // dataclip: (id: string) => loadDataclip(channel, id),
924
- };
925
- setTimeout(async () => {
926
- let loadedInput = input;
927
- if (typeof input === "string") {
928
- logger2.debug("loading dataclip", input);
987
+ setTimeout(async () => {
988
+ let loadedInput = input;
989
+ if (typeof input === "string") {
990
+ logger2.debug("loading dataclip", input);
991
+ try {
992
+ loadedInput = await loadDataclip(context, input);
993
+ logger2.success("dataclip loaded");
994
+ } catch (e) {
995
+ return onRunError(context, {
996
+ workflowId: plan.id,
997
+ message: `Failed to load dataclip ${input}${e.message ? `: ${e.message}` : ""}`,
998
+ type: "DataClipError",
999
+ severity: "exception"
1000
+ });
1001
+ }
1002
+ }
929
1003
  try {
930
- loadedInput = await loadDataclip(channel, input);
931
- logger2.success("dataclip loaded");
1004
+ Sentry2.addBreadcrumb({
1005
+ category: "run",
1006
+ message: "run metadata loaded: starting run",
1007
+ level: "info",
1008
+ data: {
1009
+ runId: plan.id
1010
+ }
1011
+ });
1012
+ engine.execute(plan, loadedInput, { resolvers, ...options });
932
1013
  } catch (e) {
933
- return onRunError(context, {
1014
+ Sentry2.addBreadcrumb({
1015
+ category: "run",
1016
+ message: "exception in run",
1017
+ level: "info",
1018
+ data: {
1019
+ runId: plan.id
1020
+ }
1021
+ });
1022
+ onRunError(context, {
934
1023
  workflowId: plan.id,
935
- message: `Failed to load dataclip ${input}${e.message ? `: ${e.message}` : ""}`,
936
- type: "DataClipError",
937
- severity: "exception"
1024
+ message: e.message,
1025
+ type: e.type,
1026
+ severity: e.severity
938
1027
  });
939
1028
  }
940
- }
941
- try {
942
- engine.execute(plan, loadedInput, { resolvers, ...options });
943
- } catch (e) {
944
- onRunError(context, {
945
- workflowId: plan.id,
946
- message: e.message,
947
- type: e.type,
948
- severity: e.severity
949
- });
950
- }
1029
+ });
951
1030
  });
952
1031
  return context;
953
1032
  }
954
- var sendEvent = (channel, event, payload) => new Promise((resolve5, reject) => {
955
- channel.push(event, payload).receive("error", reject).receive("timeout", () => {
956
- reject(new Error("timeout"));
957
- }).receive("ok", resolve5);
958
- });
959
1033
  function onJobError(context, event) {
960
1034
  const { state, error, jobId } = event;
961
1035
  if (state?.errors?.[jobId]?.message === error.message) {
@@ -964,7 +1038,8 @@ function onJobError(context, event) {
964
1038
  return onStepComplete(context, event, event.error);
965
1039
  }
966
1040
  }
967
- function onJobLog({ channel, state, options }, event) {
1041
+ function onJobLog(context, event) {
1042
+ const { state, options } = context;
968
1043
  let message = event.message;
969
1044
  if (event.redacted) {
970
1045
  message = [
@@ -984,17 +1059,17 @@ function onJobLog({ channel, state, options }, event) {
984
1059
  if (state.activeStep) {
985
1060
  log.step_id = state.activeStep;
986
1061
  }
987
- return sendEvent(channel, RUN_LOG, log);
1062
+ return sendEvent(context, RUN_LOG, log);
988
1063
  }
989
- async function loadDataclip(channel, stateId) {
990
- const result = await get_with_reply_default(channel, GET_DATACLIP, {
1064
+ async function loadDataclip(context, stateId) {
1065
+ const result = await sendEvent(context, GET_DATACLIP, {
991
1066
  id: stateId
992
1067
  });
993
1068
  const str = enc.decode(new Uint8Array(result));
994
1069
  return JSON.parse(str);
995
1070
  }
996
- async function loadCredential(channel, credentialId) {
997
- return get_with_reply_default(channel, GET_CREDENTIAL, { id: credentialId });
1071
+ async function loadCredential(context, credentialId) {
1072
+ return sendEvent(context, GET_CREDENTIAL, { id: credentialId });
998
1073
  }
999
1074
 
1000
1075
  // src/middleware/healthcheck.ts
@@ -1003,6 +1078,7 @@ var healthcheck_default = (ctx) => {
1003
1078
  };
1004
1079
 
1005
1080
  // src/channels/run.ts
1081
+ import * as Sentry3 from "@sentry/node";
1006
1082
  var joinRunChannel = (socket, token, runId, logger2) => {
1007
1083
  return new Promise((resolve5, reject) => {
1008
1084
  let didReceiveOk = false;
@@ -1013,13 +1089,18 @@ var joinRunChannel = (socket, token, runId, logger2) => {
1013
1089
  if (!didReceiveOk) {
1014
1090
  didReceiveOk = true;
1015
1091
  logger2.success(`connected to ${channelName}`, e);
1016
- const run2 = await get_with_reply_default(channel, GET_PLAN);
1092
+ const run2 = await send_event_default(
1093
+ { channel, logger: logger2, id: runId },
1094
+ GET_PLAN
1095
+ );
1017
1096
  resolve5({ channel, run: run2 });
1018
1097
  }
1019
1098
  }).receive("error", (err) => {
1099
+ Sentry3.captureException(err);
1020
1100
  logger2.error(`error connecting to ${channelName}`, err);
1021
1101
  reject(err);
1022
1102
  }).receive("timeout", (err) => {
1103
+ Sentry3.captureException(err);
1023
1104
  logger2.error(`Timeout for ${channelName}`, err);
1024
1105
  reject(err);
1025
1106
  });
@@ -1036,6 +1117,7 @@ var run_default = joinRunChannel;
1036
1117
 
1037
1118
  // src/channels/worker-queue.ts
1038
1119
  import EventEmitter2 from "node:events";
1120
+ import * as Sentry4 from "@sentry/node";
1039
1121
  import { Socket as PhxSocket } from "phoenix";
1040
1122
  import { WebSocket } from "ws";
1041
1123
  import { API_VERSION } from "@openfn/lexicon/lightning";
@@ -1064,7 +1146,17 @@ var worker_token_default = generateWorkerToken;
1064
1146
  // src/channels/worker-queue.ts
1065
1147
  var connectToWorkerQueue = (endpoint, serverId, secret, timeout = 10, logger2, SocketConstructor = PhxSocket) => {
1066
1148
  const events = new EventEmitter2();
1149
+ Sentry4.addBreadcrumb({
1150
+ category: "lifecycle",
1151
+ message: "Connecting to worker queue",
1152
+ level: "info"
1153
+ });
1067
1154
  worker_token_default(secret, serverId, logger2).then(async (token) => {
1155
+ Sentry4.addBreadcrumb({
1156
+ category: "lifecycle",
1157
+ message: "Worker token generated",
1158
+ level: "info"
1159
+ });
1068
1160
  const params = {
1069
1161
  token,
1070
1162
  api_version: API_VERSION,
@@ -1073,11 +1165,19 @@ var connectToWorkerQueue = (endpoint, serverId, secret, timeout = 10, logger2, S
1073
1165
  const socket = new SocketConstructor(endpoint, {
1074
1166
  params,
1075
1167
  transport: WebSocket,
1076
- timeout: timeout * 1e3
1168
+ timeout: timeout * 1e3,
1169
+ reconnectAfterMs: (tries) => Math.max(tries * 1e3)
1077
1170
  });
1078
1171
  let didOpen = false;
1172
+ let shouldReportConnectionError = true;
1079
1173
  socket.onOpen(() => {
1174
+ Sentry4.addBreadcrumb({
1175
+ category: "lifecycle",
1176
+ message: "Web socket connected",
1177
+ level: "info"
1178
+ });
1080
1179
  didOpen = true;
1180
+ shouldReportConnectionError = true;
1081
1181
  const channel = socket.channel("worker:queue");
1082
1182
  channel.onMessage = (ev, load) => {
1083
1183
  events.emit("message", ev, load);
@@ -1097,6 +1197,16 @@ var connectToWorkerQueue = (endpoint, serverId, secret, timeout = 10, logger2, S
1097
1197
  events.emit("disconnect");
1098
1198
  });
1099
1199
  socket.onError((e) => {
1200
+ Sentry4.addBreadcrumb({
1201
+ category: "lifecycle",
1202
+ message: "Error in web socket connection",
1203
+ level: "info"
1204
+ });
1205
+ if (shouldReportConnectionError) {
1206
+ logger2.debug("Reporting connection error to sentry");
1207
+ shouldReportConnectionError = false;
1208
+ Sentry4.captureException(e);
1209
+ }
1100
1210
  if (!didOpen) {
1101
1211
  events.emit("error", e.message);
1102
1212
  didOpen = false;
@@ -1201,6 +1311,13 @@ function createServer(engine, options = {}) {
1201
1311
  const router = new Router();
1202
1312
  app.events = new EventEmitter3();
1203
1313
  app.engine = engine;
1314
+ if (options.sentryDsn) {
1315
+ Sentry5.init({
1316
+ environment: options.sentryEnv,
1317
+ dsn: options.sentryDsn
1318
+ });
1319
+ Sentry5.setupKoaErrorHandler(app);
1320
+ }
1204
1321
  app.use(bodyParser());
1205
1322
  app.use(
1206
1323
  koaLogger((str, _args) => {
@@ -6209,6 +6326,8 @@ function parseArgs(argv) {
6209
6326
  WORKER_PORT,
6210
6327
  WORKER_REPO_DIR,
6211
6328
  WORKER_SECRET,
6329
+ WORKER_SENTRY_DSN,
6330
+ WORKER_SENTRY_ENV,
6212
6331
  WORKER_STATE_PROPS_TO_REMOVE,
6213
6332
  WORKER_SOCKET_TIMEOUT_SECONDS,
6214
6333
  OPENFN_ADAPTORS_REPO
@@ -6229,6 +6348,11 @@ function parseArgs(argv) {
6229
6348
  }).option("secret", {
6230
6349
  alias: "s",
6231
6350
  description: "Worker secret. (comes from WORKER_SECRET by default). Env: WORKER_SECRET"
6351
+ }).option("sentry-dsn", {
6352
+ alias: ["dsn"],
6353
+ description: "Sentry DSN. Env: WORKER_SENTRY_DSN"
6354
+ }).option("sentry-env", {
6355
+ description: "Sentry environment. Defaults to 'dev'. Env: WORKER_SENTRY_ENV"
6232
6356
  }).option("socket-timeout", {
6233
6357
  description: `Timeout for websockets to Lighting, in seconds. Defaults to 10.`
6234
6358
  }).option("lightning-public-key", {
@@ -6280,6 +6404,8 @@ function parseArgs(argv) {
6280
6404
  repoDir: setArg(args2.repoDir, WORKER_REPO_DIR),
6281
6405
  monorepoDir: setArg(args2.monorepoDir, OPENFN_ADAPTORS_REPO),
6282
6406
  secret: setArg(args2.secret, WORKER_SECRET),
6407
+ sentryDsn: setArg(args2.sentryDsn, WORKER_SENTRY_DSN),
6408
+ sentryEnv: setArg(args2.sentryEnv, WORKER_SENTRY_ENV, "dev"),
6283
6409
  lightningPublicKey: setArg(
6284
6410
  args2.lightningPublicKey,
6285
6411
  WORKER_LIGHTNING_PUBLIC_KEY
@@ -6333,6 +6459,8 @@ function engineReady(engine) {
6333
6459
  lightning: args.lightning,
6334
6460
  logger,
6335
6461
  secret: args.secret,
6462
+ sentryDsn: args.sentryDsn,
6463
+ sentryEnv: args.sentryEnv,
6336
6464
  noLoop: !args.loop,
6337
6465
  // TODO need to feed this through properly
6338
6466
  backoff: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfn/ws-worker",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "A Websocket Worker to connect Lightning to a Runtime Engine",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "license": "ISC",
12
12
  "dependencies": {
13
13
  "@koa/router": "^12.0.0",
14
+ "@sentry/node": "^9.5.0",
14
15
  "@types/koa-logger": "^3.1.2",
15
16
  "@types/ws": "^8.5.6",
16
17
  "fast-safe-stringify": "^2.1.1",
@@ -22,9 +23,9 @@
22
23
  "koa-logger": "^3.2.1",
23
24
  "phoenix": "1.7.10",
24
25
  "ws": "^8.18.0",
25
- "@openfn/engine-multi": "1.6.0",
26
- "@openfn/logger": "1.0.4",
27
- "@openfn/runtime": "1.6.3",
26
+ "@openfn/engine-multi": "1.6.2",
27
+ "@openfn/logger": "1.0.5",
28
+ "@openfn/runtime": "1.6.4",
28
29
  "@openfn/lexicon": "^1.2.0"
29
30
  },
30
31
  "devDependencies": {
@@ -37,11 +38,12 @@
37
38
  "@types/yargs": "^17.0.12",
38
39
  "ava": "5.1.0",
39
40
  "nodemon": "3.0.1",
41
+ "sentry-testkit": "^6.1.0",
40
42
  "tslib": "^2.4.0",
41
43
  "tsup": "^6.2.3",
42
44
  "typescript": "^4.6.4",
43
45
  "yargs": "^17.6.2",
44
- "@openfn/lightning-mock": "2.1.2"
46
+ "@openfn/lightning-mock": "2.1.4"
45
47
  },
46
48
  "files": [
47
49
  "dist",