@elastic/synthetics 1.24.4 → 1.26.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.
Files changed (119) hide show
  1. package/README.md +67 -0
  2. package/dist/cli.js +3 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/common_types.d.ts +25 -0
  5. package/dist/common_types.d.ts.map +1 -1
  6. package/dist/core/certs.d.ts +55 -0
  7. package/dist/core/certs.d.ts.map +1 -0
  8. package/dist/core/certs.js +111 -0
  9. package/dist/core/certs.js.map +1 -0
  10. package/dist/core/gatherer.d.ts +4 -4
  11. package/dist/core/gatherer.d.ts.map +1 -1
  12. package/dist/core/gatherer.js +44 -7
  13. package/dist/core/gatherer.js.map +1 -1
  14. package/dist/core/globals.js +1 -1
  15. package/dist/core/index.d.ts +2 -1
  16. package/dist/core/index.d.ts.map +1 -1
  17. package/dist/core/index.js +16 -1
  18. package/dist/core/index.js.map +1 -1
  19. package/dist/core/runner.d.ts +1 -1
  20. package/dist/core/runner.d.ts.map +1 -1
  21. package/dist/core/runner.js +87 -58
  22. package/dist/core/runner.js.map +1 -1
  23. package/dist/dsl/api-journey.d.ts +62 -0
  24. package/dist/dsl/api-journey.d.ts.map +1 -0
  25. package/dist/dsl/api-journey.js +55 -0
  26. package/dist/dsl/api-journey.js.map +1 -0
  27. package/dist/dsl/index.d.ts +1 -0
  28. package/dist/dsl/index.d.ts.map +1 -1
  29. package/dist/dsl/index.js +1 -0
  30. package/dist/dsl/index.js.map +1 -1
  31. package/dist/dsl/journey.d.ts +10 -2
  32. package/dist/dsl/journey.d.ts.map +1 -1
  33. package/dist/dsl/journey.js +11 -2
  34. package/dist/dsl/journey.js.map +1 -1
  35. package/dist/dsl/monitor.d.ts +2 -1
  36. package/dist/dsl/monitor.d.ts.map +1 -1
  37. package/dist/dsl/monitor.js.map +1 -1
  38. package/dist/index.d.ts +2 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/loader.d.ts.map +1 -1
  43. package/dist/loader.js +66 -1
  44. package/dist/loader.js.map +1 -1
  45. package/dist/network-timings.d.ts +55 -0
  46. package/dist/network-timings.d.ts.map +1 -0
  47. package/dist/network-timings.js +107 -0
  48. package/dist/network-timings.js.map +1 -0
  49. package/dist/options.d.ts +1 -0
  50. package/dist/options.d.ts.map +1 -1
  51. package/dist/options.js +16 -0
  52. package/dist/options.js.map +1 -1
  53. package/dist/plugins/api-network.d.ts +39 -0
  54. package/dist/plugins/api-network.d.ts.map +1 -0
  55. package/dist/plugins/api-network.js +235 -0
  56. package/dist/plugins/api-network.js.map +1 -0
  57. package/dist/plugins/network.d.ts +0 -6
  58. package/dist/plugins/network.d.ts.map +1 -1
  59. package/dist/plugins/network.js +5 -70
  60. package/dist/plugins/network.js.map +1 -1
  61. package/dist/plugins/plugin-manager.d.ts +11 -4
  62. package/dist/plugins/plugin-manager.d.ts.map +1 -1
  63. package/dist/plugins/plugin-manager.js +36 -16
  64. package/dist/plugins/plugin-manager.js.map +1 -1
  65. package/dist/push/index.d.ts.map +1 -1
  66. package/dist/push/index.js +4 -0
  67. package/dist/push/index.js.map +1 -1
  68. package/dist/push/monitor.d.ts.map +1 -1
  69. package/dist/push/monitor.js +1 -1
  70. package/dist/push/monitor.js.map +1 -1
  71. package/dist/push/run-local.d.ts.map +1 -1
  72. package/dist/push/run-local.js +15 -3
  73. package/dist/push/run-local.js.map +1 -1
  74. package/dist/push/utils.d.ts +7 -0
  75. package/dist/push/utils.d.ts.map +1 -1
  76. package/dist/push/utils.js +15 -1
  77. package/dist/push/utils.js.map +1 -1
  78. package/dist/reporters/base.d.ts +2 -2
  79. package/dist/reporters/base.d.ts.map +1 -1
  80. package/dist/reporters/base.js.map +1 -1
  81. package/dist/reporters/index.d.ts +2 -2
  82. package/dist/reporters/index.d.ts.map +1 -1
  83. package/dist/reporters/index.js.map +1 -1
  84. package/dist/reporters/json.d.ts +6 -2
  85. package/dist/reporters/json.d.ts.map +1 -1
  86. package/dist/reporters/json.js +68 -33
  87. package/dist/reporters/json.js.map +1 -1
  88. package/package.json +11 -21
  89. package/src/cli.ts +3 -0
  90. package/src/common_types.ts +34 -0
  91. package/src/core/certs.ts +122 -0
  92. package/src/core/gatherer.ts +63 -10
  93. package/src/core/globals.ts +1 -1
  94. package/src/core/index.ts +28 -0
  95. package/src/core/runner.ts +94 -67
  96. package/src/dsl/api-journey.ts +93 -0
  97. package/src/dsl/index.ts +1 -0
  98. package/src/dsl/journey.ts +21 -9
  99. package/src/dsl/monitor.ts +2 -0
  100. package/src/index.ts +6 -0
  101. package/src/loader.ts +78 -2
  102. package/src/network-timings.ts +130 -0
  103. package/src/options.ts +25 -0
  104. package/src/plugins/api-network.ts +252 -0
  105. package/src/plugins/network.ts +5 -87
  106. package/src/plugins/plugin-manager.ts +60 -19
  107. package/src/push/index.ts +11 -0
  108. package/src/push/monitor.ts +6 -2
  109. package/src/push/run-local.ts +25 -15
  110. package/src/push/utils.ts +15 -0
  111. package/src/reporters/base.ts +2 -1
  112. package/src/reporters/index.ts +2 -1
  113. package/src/reporters/json.ts +74 -41
  114. package/templates/README.md +3 -1
  115. package/templates/journeys/advanced-api-example-helpers.ts +79 -0
  116. package/templates/journeys/advanced-api-example.journey.ts +52 -0
  117. package/templates/journeys/api-example.journey.ts +35 -0
  118. package/templates/synthetics.config.ts +2 -0
  119. package/.husky/install.mjs +0 -7
@@ -51,6 +51,7 @@ import {
51
51
  JourneyStartResult,
52
52
  StepEndResult,
53
53
  JourneyEndResult,
54
+ APIJourneyEndResult,
54
55
  PageMetrics,
55
56
  } from '../common_types';
56
57
  import { inspect } from 'util';
@@ -137,6 +138,25 @@ function getMetadata() {
137
138
  };
138
139
  }
139
140
 
141
+ // ECS `server.ip`/`server.port`. Emitted for API journeys only; browser
142
+ // output keeps the address under `http.response` to preserve its shape.
143
+ function formatServer(response: NetworkInfo['response']) {
144
+ if (!response?.remoteIPAddress && !response?.remotePort) return undefined;
145
+ return {
146
+ ip: response.remoteIPAddress,
147
+ port: response.remotePort,
148
+ };
149
+ }
150
+
151
+ // Epoch seconds -> ISO-8601, tolerating missing/non-finite input. A
152
+ // malformed cert date would otherwise throw `RangeError` and sink the
153
+ // whole `journey/end` document (see json.test.ts regression).
154
+ function epochToIso(epochSeconds: number | undefined): string | undefined {
155
+ if (epochSeconds == null || !Number.isFinite(epochSeconds)) return undefined;
156
+ const date = new Date(epochSeconds * 1000);
157
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
158
+ }
159
+
140
160
  function formatTLS(tls: SecurityDetails) {
141
161
  if (!tls || !tls.protocol) {
142
162
  return;
@@ -151,8 +171,8 @@ function formatTLS(tls: SecurityDetails) {
151
171
  subject: {
152
172
  common_name: tls.subjectName,
153
173
  },
154
- not_after: new Date(tls.validTo * 1000).toISOString(),
155
- not_before: new Date(tls.validFrom * 1000).toISOString(),
174
+ not_after: epochToIso(tls.validTo),
175
+ not_before: epochToIso(tls.validFrom),
156
176
  },
157
177
  },
158
178
  version_protocol: name,
@@ -208,6 +228,7 @@ export function formatNetworkFields(network: NetworkInfo) {
208
228
  response,
209
229
  },
210
230
  tls: formatTLS(response?.securityDetails),
231
+ ...(browser?.name === 'api' && { server: formatServer(response) }),
211
232
  };
212
233
 
213
234
  const pickItems: Array<keyof NetworkInfo> = [
@@ -257,10 +278,15 @@ function journeyInfo(journey: Partial<Journey>, type: OutputFields['type']) {
257
278
  if (!journey) {
258
279
  return;
259
280
  }
281
+ // Surface non-default journey types (e.g. `api`) for downstream routing;
282
+ // browser output omits the field to stay byte-compatible.
283
+ const journeyType =
284
+ journey.type && journey.type !== 'browser' ? journey.type : undefined;
260
285
  const info: JourneyInfo = {
261
286
  name: journey.name,
262
287
  id: journey.id,
263
288
  tags: journey.tags,
289
+ ...(journeyType ? { type: journeyType } : {}),
264
290
  };
265
291
  const isEnd = type === 'journey/end';
266
292
  if (isEnd) {
@@ -439,40 +465,42 @@ export default class JSONReporter extends BaseReporter {
439
465
 
440
466
  override async onJourneyEnd(
441
467
  journey: Journey,
442
- {
443
- timestamp,
444
- browserDelay,
445
- networkinfo,
446
- browserconsole,
447
- options,
448
- }: JourneyEndResult
468
+ result: JourneyEndResult | APIJourneyEndResult
449
469
  ) {
450
- const { ssblocks, screenshots } = options;
451
- const writeScreenshots =
452
- screenshots === 'on' ||
453
- (screenshots === 'only-on-failure' && journey.status === 'failed');
454
- if (writeScreenshots) {
455
- await gatherScreenshots(
456
- join(CACHE_PATH, 'screenshots'),
457
- async screenshot => {
458
- const { data, timestamp, step } = screenshot;
459
- if (!data) {
460
- return;
461
- }
462
- if (ssblocks) {
463
- await this.writeScreenshotBlocks(journey, screenshot);
464
- } else {
465
- this.writeJSON({
466
- type: 'step/screenshot',
467
- timestamp,
468
- journey,
469
- step,
470
- blob: data,
471
- blob_mime: 'image/jpeg',
472
- });
470
+ const { timestamp, networkinfo, options } = result;
471
+ const isAPIJourney = journey.type === 'api';
472
+ // Browser-only fields; absent for API journeys.
473
+ const browserDelay = (result as JourneyEndResult).browserDelay;
474
+ const browserconsole = (result as JourneyEndResult).browserconsole;
475
+
476
+ if (!isAPIJourney) {
477
+ const { ssblocks, screenshots } = options;
478
+ const writeScreenshots =
479
+ screenshots === 'on' ||
480
+ (screenshots === 'only-on-failure' && journey.status === 'failed');
481
+ if (writeScreenshots) {
482
+ await gatherScreenshots(
483
+ join(CACHE_PATH, 'screenshots'),
484
+ async screenshot => {
485
+ const { data, timestamp, step } = screenshot;
486
+ if (!data) {
487
+ return;
488
+ }
489
+ if (ssblocks) {
490
+ await this.writeScreenshotBlocks(journey, screenshot);
491
+ } else {
492
+ this.writeJSON({
493
+ type: 'step/screenshot',
494
+ timestamp,
495
+ journey,
496
+ step,
497
+ blob: data,
498
+ blob_mime: 'image/jpeg',
499
+ });
500
+ }
473
501
  }
474
- }
475
- );
502
+ );
503
+ }
476
504
  }
477
505
 
478
506
  if (networkinfo) {
@@ -504,18 +532,23 @@ export default class JSONReporter extends BaseReporter {
504
532
  });
505
533
  }
506
534
 
535
+ const endPayload: Payload = {
536
+ status: journey.status,
537
+ // Omit `browser_delay_us` for API journeys where it has no meaning;
538
+ // key order preserves the existing browser wire shape.
539
+ ...(isAPIJourney
540
+ ? {}
541
+ : { browser_delay_us: getDurationInUs(browserDelay) }),
542
+ // timestamp in microseconds at which the current node process began, measured in Unix time.
543
+ process_startup_epoch_us: Math.trunc(processStart * 1000),
544
+ };
545
+
507
546
  this.writeJSON({
508
547
  type: 'journey/end',
509
548
  journey,
510
549
  timestamp,
511
550
  error: journey.error,
512
- payload: {
513
- status: journey.status,
514
- // convert from monotonic seconds time to microseconds
515
- browser_delay_us: getDurationInUs(browserDelay),
516
- // timestamp in microseconds at which the current node process began, measured in Unix time.
517
- process_startup_epoch_us: Math.trunc(processStart * 1000),
518
- },
551
+ payload: endPayload,
519
552
  });
520
553
  }
521
554
 
@@ -9,7 +9,9 @@ You can always use this command to scafold new Synthetics projects.
9
9
  Key places to look:
10
10
 
11
11
  - The `synthetics.config.ts` file contains configuration for your project.
12
- - The `journeys` directory contains both basic and more advanced examples of using synthetics. It tests a publicly hosted [Todos List](https://elastic.github.io/synthetics-demo/).
12
+ - The `journeys` directory contains both basic and more advanced examples of using synthetics:
13
+ - `example.journey.ts` and `advanced-example.journey.ts` are **browser journeys** that drive Chromium against a publicly hosted [Todos List](https://elastic.github.io/synthetics-demo/).
14
+ - `api-example.journey.ts` and `advanced-api-example.journey.ts` are **API journeys** that run without a browser, hitting [jsonplaceholder.typicode.com](https://jsonplaceholder.typicode.com) for a quick, runnable demonstration of multi-step API monitoring. Use these for OAuth-protected endpoints, multi-step API flows, and high-frequency lightweight checks.
13
15
  - The `.github` directory contains an example github action, demonstrating the use of a CI service for automatically running tests on merges and PR creation.
14
16
 
15
17
  To run the tests locally, use the following command:
@@ -0,0 +1,79 @@
1
+ import {
2
+ step,
3
+ APIRequestContext,
4
+ APIResponse,
5
+ expect,
6
+ } from '@elastic/synthetics';
7
+
8
+ // Helpers for `advanced-api-example.journey.ts` — same idea as
9
+ // `advanced-example-helpers.ts` for browser journeys, but operating on
10
+ // `APIRequestContext` instead of `Page`. Wrapping each call in a `step`
11
+ // gives you per-request timing, status, and error attribution in the
12
+ // reporter output.
13
+
14
+ /**
15
+ * Generic "assert this call returns the expected status" wrapper. Useful
16
+ * when a step is just a single request with a known good status code
17
+ * and you don't need to inspect the body.
18
+ */
19
+ export const expectStatusStep = (
20
+ name: string,
21
+ call: () => Promise<APIResponse>,
22
+ expectedStatus = 200
23
+ ) => {
24
+ step(name, async () => {
25
+ const res = await call();
26
+ expect(res.status()).toBe(expectedStatus);
27
+ });
28
+ };
29
+
30
+ export const listTodosStep = (
31
+ request: APIRequestContext,
32
+ apiUrl: string,
33
+ state: { ids: number[] }
34
+ ) => {
35
+ step('list todos', async () => {
36
+ const res = await request.get(`${apiUrl}/todos?_limit=5`);
37
+ expect(res.status()).toBe(200);
38
+ const todos: Array<{ id: number }> = await res.json();
39
+ expect(todos.length).toBeGreaterThan(0);
40
+ state.ids = todos.map(t => t.id);
41
+ });
42
+ };
43
+
44
+ export const createTodoStep = (
45
+ request: APIRequestContext,
46
+ apiUrl: string,
47
+ title: string,
48
+ state: { createdId?: number }
49
+ ) => {
50
+ step(`create todo "${title}"`, async () => {
51
+ const res = await request.post(`${apiUrl}/todos`, {
52
+ data: { title, completed: false, userId: 1 },
53
+ });
54
+ expect(res.status()).toBe(201);
55
+ const created = await res.json();
56
+ expect(created).toMatchObject({ title, completed: false });
57
+ state.createdId = created.id;
58
+ });
59
+ };
60
+
61
+ /**
62
+ * Delete a todo whose id is produced by an earlier step. The id is
63
+ * supplied as a thunk so the step builder can be registered up-front
64
+ * but resolve the actual id lazily, at execution time, after the
65
+ * earlier step has populated state.
66
+ */
67
+ export const deleteTodoStep = (
68
+ request: APIRequestContext,
69
+ apiUrl: string,
70
+ getId: () => number | undefined
71
+ ) => {
72
+ step('delete created todo', async () => {
73
+ const id = getId();
74
+ if (id == null) return; // earlier step didn't produce an id; skip silently
75
+ const res = await request.delete(`${apiUrl}/todos/${id}`);
76
+ // jsonplaceholder returns 200 for DELETE; real APIs typically 204.
77
+ expect([200, 204]).toContain(res.status());
78
+ });
79
+ };
@@ -0,0 +1,52 @@
1
+ import { apiJourney, monitor, step, expect } from '@elastic/synthetics';
2
+ import {
3
+ listTodosStep,
4
+ createTodoStep,
5
+ deleteTodoStep,
6
+ } from './advanced-api-example-helpers';
7
+
8
+ // This file demonstrates the recommended shape for non-trivial API
9
+ // monitors: small, named, reusable step builders, with shared state
10
+ // passed by reference between steps so later steps can act on data
11
+ // produced by earlier ones (e.g. a token from `/auth`, an id from
12
+ // `/create`, etc.).
13
+ //
14
+ // Each step gets its own entry in the reporter output, so you get
15
+ // per-request status, latency, and (for HTTPS) TLS / remote-address
16
+ // telemetry — same as a browser journey's network waterfall.
17
+ //
18
+ // Note on execution order: the journey callback below runs once at
19
+ // *registration* time and every `step(...)` call enqueues a step body
20
+ // that executes later, in order. We register all steps unconditionally
21
+ // at the top level and read `state` only inside step bodies — by the
22
+ // time a step body runs, the steps before it have already populated
23
+ // the shared object. If any prerequisite step fails, the runner skips
24
+ // the rest of the journey, so guards inside later steps are kept
25
+ // minimal.
26
+
27
+ apiJourney('todos API: list and round-trip a todo', ({ request, params }) => {
28
+ monitor.use({
29
+ id: 'todos-api-roundtrip',
30
+ schedule: 10,
31
+ tags: ['api', 'todos'],
32
+ });
33
+
34
+ // Shared state populated by earlier steps and consumed by later ones.
35
+ const state: { ids: number[]; createdId?: number } = { ids: [] };
36
+
37
+ listTodosStep(request, params.apiUrl, state);
38
+ createTodoStep(request, params.apiUrl, 'synthetic check', state);
39
+
40
+ // Inline step that reads `state` populated by `listTodosStep`. The
41
+ // body runs after the previous steps have completed, so by this
42
+ // point `state.ids` is populated.
43
+ step('fetch first todo from list', async () => {
44
+ expect(state.ids.length).toBeGreaterThan(0);
45
+ const id = state.ids[0];
46
+ const res = await request.get(`${params.apiUrl}/todos/${id}`);
47
+ expect(res.status()).toBe(200);
48
+ expect((await res.json()).id).toBe(id);
49
+ });
50
+
51
+ deleteTodoStep(request, params.apiUrl, () => state.createdId);
52
+ });
@@ -0,0 +1,35 @@
1
+ import { apiJourney, monitor, step, expect } from '@elastic/synthetics';
2
+
3
+ // API journeys run without launching a browser — they only use
4
+ // Playwright's APIRequestContext to hit HTTP endpoints. They are pushed
5
+ // to Kibana as HTTP-type monitors and are a good fit for OAuth-protected
6
+ // endpoints, multi-step API flows, and lightweight high-frequency checks.
7
+ //
8
+ // `params.apiUrl` is sourced from `synthetics.config.ts` and points at
9
+ // the jsonplaceholder.typicode.com demo API by default; override it for
10
+ // your own service via `--params '{"apiUrl":"..."}'` or per-environment
11
+ // in the config file.
12
+
13
+ apiJourney('My Example API Journey', ({ request, params }) => {
14
+ // Only relevant for the push command to create
15
+ // monitors in Kibana
16
+ monitor.use({
17
+ id: 'example-api-monitor',
18
+ schedule: 10,
19
+ });
20
+
21
+ step('list todos', async () => {
22
+ const res = await request.get(`${params.apiUrl}/todos`);
23
+ expect(res.status()).toBe(200);
24
+ const todos = await res.json();
25
+ expect(Array.isArray(todos)).toBe(true);
26
+ expect(todos.length).toBeGreaterThan(0);
27
+ });
28
+
29
+ step('fetch a single todo', async () => {
30
+ const res = await request.get(`${params.apiUrl}/todos/1`);
31
+ expect(res.status()).toBe(200);
32
+ const todo = await res.json();
33
+ expect(todo).toMatchObject({ id: 1, userId: expect.any(Number) });
34
+ });
35
+ });
@@ -4,6 +4,8 @@ export default env => {
4
4
  const config: SyntheticsConfig = {
5
5
  params: {
6
6
  url: 'https://elastic.github.io/synthetics-demo/',
7
+ // Base URL for the API journey examples; replace with your service.
8
+ apiUrl: 'https://jsonplaceholder.typicode.com',
7
9
  },
8
10
  playwrightOptions: {
9
11
  ignoreHTTPSErrors: false,
@@ -1,7 +0,0 @@
1
- // Skip if husky is not installed (CI, production, embedded installs with --omit=dev)
2
- try {
3
- const husky = await import('husky');
4
- husky.default();
5
- } catch (e) {
6
- if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e;
7
- }