@toa.io/extensions.exposition 1.0.0-alpha.245 → 1.0.0-alpha.246

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 (44) hide show
  1. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  2. package/documentation/tracing.md +70 -0
  3. package/features/steps/Gateway.ts +1 -4
  4. package/features/steps/Parameters.ts +9 -3
  5. package/features/steps/components/audit/manifest.toa.yaml +13 -0
  6. package/features/steps/components/audit/operations/record.js +9 -0
  7. package/features/steps/components/notify/manifest.toa.yaml +13 -0
  8. package/features/steps/components/notify/operations/send.js +9 -0
  9. package/features/steps/components/orders/manifest.toa.yaml +15 -0
  10. package/features/steps/components/orders/operations/create.js +9 -0
  11. package/features/steps/components/pricing/manifest.toa.yaml +7 -0
  12. package/features/steps/components/pricing/operations/quote.js +7 -0
  13. package/features/timing.feature +2 -22
  14. package/features/tracing.feature +123 -0
  15. package/package.json +6 -6
  16. package/readme.md +7 -1
  17. package/schemas/annotation.cos.yaml +0 -1
  18. package/source/Annotation.ts +0 -1
  19. package/source/Directive.ts +22 -4
  20. package/source/Factory.ts +8 -4
  21. package/source/HTTP/Context.ts +1 -2
  22. package/source/HTTP/Server.ts +66 -13
  23. package/source/HTTP/Timing.ts +0 -11
  24. package/source/HTTP/messages.test.ts +1 -1
  25. package/source/RTD/Directives.ts +3 -0
  26. package/source/deployment.ts +2 -5
  27. package/transpiled/Annotation.d.ts +0 -1
  28. package/transpiled/Directive.js +16 -4
  29. package/transpiled/Directive.js.map +1 -1
  30. package/transpiled/Factory.js +4 -1
  31. package/transpiled/Factory.js.map +1 -1
  32. package/transpiled/HTTP/Context.d.ts +0 -1
  33. package/transpiled/HTTP/Context.js +1 -1
  34. package/transpiled/HTTP/Context.js.map +1 -1
  35. package/transpiled/HTTP/Server.d.ts +1 -1
  36. package/transpiled/HTTP/Server.js +49 -9
  37. package/transpiled/HTTP/Server.js.map +1 -1
  38. package/transpiled/HTTP/Timing.d.ts +0 -2
  39. package/transpiled/HTTP/Timing.js +0 -8
  40. package/transpiled/HTTP/Timing.js.map +1 -1
  41. package/transpiled/RTD/Directives.d.ts +2 -0
  42. package/transpiled/deployment.js +1 -3
  43. package/transpiled/deployment.js.map +1 -1
  44. package/transpiled/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,70 @@
1
+ # Tracing
2
+
3
+ Each incoming request is processed within a [trace](../../telemetry/readme.md#tracing) —
4
+ a server span is created, and its trace context is propagated to component invocations and
5
+ emitted events, so all log entries and spans across the application caused by the request
6
+ share the same `trace_id`.
7
+
8
+ ## The `ray` response header
9
+
10
+ Each response contains a `ray` header with the trace ID of the request.
11
+ It can be used to find related log entries, or be attached to a support ticket.
12
+
13
+ Log entries are stamped with the trace ID regardless of the sampling decision,
14
+ while the trace itself is only recorded if the trace is sampled (see [Sampling](#sampling)).
15
+
16
+ ```
17
+ HTTP/1.1 201 Created
18
+ ray: 0af7651916cd43dd8448eb211c80319c
19
+ ```
20
+
21
+ ## Continuing a trace
22
+
23
+ By default, the trace starts at the gateway. If the request carries a trace context in one of the
24
+ following headers, the trace is continued, that is, the incoming trace ID is used.
25
+
26
+ ### `traceparent`
27
+
28
+ The [W3C Trace Context](https://www.w3.org/TR/trace-context/#traceparent-header) header, sent by
29
+ clients instrumented with OpenTelemetry-compatible tooling, or by upstream proxies.
30
+
31
+ ```
32
+ traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
33
+ ```
34
+
35
+ In addition to the trace ID, it carries the ID of the client span, which becomes the *parent* of
36
+ the gateway's server span, linking client-side and server-side spans into a single trace tree.
37
+
38
+ ### `ray`
39
+
40
+ A bare trace ID (32 lowercase hex digits), for clients without tracing instrumentation willing to
41
+ correlate their requests with server-side traces.
42
+
43
+ ```
44
+ ray: 4bf92f3577b34da6a3ce929d0e0e4736
45
+ ```
46
+
47
+ Unlike `traceparent`, it carries no parent span, so the gateway's server span becomes the root of
48
+ the trace.
49
+
50
+ If both headers are present, `traceparent` takes precedence as it carries more information.
51
+ Headers with invalid values are ignored, and a new trace is started.
52
+
53
+ ## Sampling
54
+
55
+ The trace *context* (trace ID) is always created and propagated, so the `ray` header and
56
+ `trace_id` in log entries are always present. The sampling decision only determines whether
57
+ the *spans* of the trace are recorded.
58
+
59
+ The decision is made once, by the process that starts the trace, and is propagated along with
60
+ the trace context (the flags byte of `traceparent`), so a trace is either recorded as a whole
61
+ or not at all. Sampling is configured with the
62
+ [`telemetry.traces` annotation](../../telemetry/readme.md#sampling).
63
+
64
+ Sending a trace context does not bypass sampling: an incoming `ray` header adopts the trace ID,
65
+ while the recording decision is still made by the gateway. The `sampled` flag of an incoming
66
+ `traceparent` is respected, as the decision belongs to the trace root.
67
+
68
+ ## See also
69
+
70
+ - [Telemetry extension](../../telemetry/readme.md) — spans, structured logs, `context.span`
@@ -24,15 +24,12 @@ export class Gateway {
24
24
  process.env.TOA_EXPOSITION = encode(tree)
25
25
  }
26
26
 
27
- const { debug, trace, authorities } = annotation
27
+ const { debug, authorities } = annotation
28
28
  const properties = Object.assign({}, DEFAULT_PROPERTIES)
29
29
 
30
30
  if (debug !== undefined)
31
31
  properties.debug = debug
32
32
 
33
- if (trace !== undefined)
34
- properties.trace = trace
35
-
36
33
  if (authorities !== undefined)
37
34
  properties.authorities = authorities
38
35
 
@@ -1,7 +1,6 @@
1
1
  import { join } from 'node:path'
2
2
  import * as dotenv from 'dotenv'
3
3
  import { setDefaultTimeout } from '@cucumber/cucumber'
4
- import { console } from 'openspan'
5
4
  import { encode } from '@toa.io/generic'
6
5
 
7
6
  dotenv.config({ path: join(__dirname, '.env') })
@@ -16,10 +15,17 @@ export class Parameters {
16
15
 
17
16
  setDefaultTimeout(60 * 1000)
18
17
 
19
- console.configure({ format: 'terminal' })
20
-
21
18
  process.env.TOA_DEV = '1'
22
19
 
20
+ // export traces to the local Tempo (`docker compose up tempo grafana`),
21
+ // unavailability of the endpoint is harmless
22
+ process.env.TOA_TELEMETRY_TRACES ??= encode({
23
+ exporters: {
24
+ console: null,
25
+ otlp: { endpoint: 'http://localhost:4318' }
26
+ }
27
+ })
28
+
23
29
  process.env.TOA_STORAGES = encode({
24
30
  octets: {
25
31
  provider: 'tmp',
@@ -0,0 +1,13 @@
1
+ name: audit
2
+
3
+ operations:
4
+ record:
5
+ input:
6
+ id: string
7
+ title: string
8
+ volume: number
9
+ price: number
10
+ output: undefined
11
+
12
+ receivers:
13
+ orders.created: record
@@ -0,0 +1,9 @@
1
+ 'use strict'
2
+
3
+ async function effect (input) {
4
+ await new Promise((resolve) => setTimeout(resolve, 500))
5
+
6
+ return null
7
+ }
8
+
9
+ exports.effect = effect
@@ -0,0 +1,13 @@
1
+ name: notify
2
+
3
+ operations:
4
+ send:
5
+ input:
6
+ id: string
7
+ title: string
8
+ volume: number
9
+ price: number
10
+ output: undefined
11
+
12
+ receivers:
13
+ orders.created: send
@@ -0,0 +1,9 @@
1
+ 'use strict'
2
+
3
+ async function effect (input) {
4
+ await new Promise((resolve) => setTimeout(resolve, 800))
5
+
6
+ return null
7
+ }
8
+
9
+ exports.effect = effect
@@ -0,0 +1,15 @@
1
+ name: orders
2
+
3
+ entity:
4
+ schema:
5
+ title: string
6
+ volume: number
7
+ price?: number
8
+
9
+ operations:
10
+ create:
11
+ concurrency: retry
12
+ query: false
13
+ input:
14
+ title*: .
15
+ volume*: .
@@ -0,0 +1,9 @@
1
+ 'use strict'
2
+
3
+ async function transition (input, object, context) {
4
+ const price = await context.remote.default.pricing.quote({ input: { volume: input.volume } })
5
+
6
+ return Object.assign(object, { ...input, price })
7
+ }
8
+
9
+ exports.transition = transition
@@ -0,0 +1,7 @@
1
+ name: pricing
2
+
3
+ operations:
4
+ quote:
5
+ input:
6
+ volume*: number
7
+ output: number
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ async function computation (input) {
4
+ return input.volume * 42
5
+ }
6
+
7
+ exports.computation = computation
@@ -8,26 +8,7 @@ Feature: Server timing
8
8
  POST: create
9
9
  """
10
10
 
11
- Scenario: Server timing is not available by default
12
- When the following request is received:
13
- """
14
- POST /pots/ HTTP/1.1
15
- host: nex.toa.io
16
- content-type: application/yaml
17
-
18
- title: Hello
19
- volume: 1.5
20
- """
21
- Then the reply does not contain:
22
- """
23
- server-timing:
24
- """
25
-
26
- Scenario: Server timing is sent when `trace` is enabled
27
- Given the annotation:
28
- """
29
- trace: true
30
- """
11
+ Scenario: Server timing is sent
31
12
  When the following request is received:
32
13
  """
33
14
  POST /pots/ HTTP/1.1
@@ -47,13 +28,12 @@ Feature: Server timing
47
28
  Scenario: Octets timing
48
29
  Given the annotation:
49
30
  """yaml
50
- trace: true
51
31
  /:
52
32
  io:output: true
53
33
  auth:anonymous: true
54
34
  octets:context: octets
55
35
  POST:
56
- octets:store: ~
36
+ octets:put: ~
57
37
  """
58
38
  When the stream of `lenna.png` is received with the following headers:
59
39
  """
@@ -0,0 +1,123 @@
1
+ Feature: Distributed tracing
2
+
3
+ Background:
4
+ Given the `pots` is running with the following manifest:
5
+ """yaml
6
+ exposition:
7
+ /:
8
+ POST: create
9
+ """
10
+
11
+ Scenario: Response carries the `ray` header
12
+ When the following request is received:
13
+ """
14
+ POST /pots/ HTTP/1.1
15
+ host: nex.toa.io
16
+ content-type: application/yaml
17
+
18
+ title: Hello
19
+ volume: 1.5
20
+ """
21
+ Then the following reply is sent:
22
+ """
23
+ 201 Created
24
+ ray:
25
+ """
26
+
27
+ Scenario: Trace is continued from the `traceparent` request header
28
+ When the following request is received:
29
+ """
30
+ POST /pots/ HTTP/1.1
31
+ host: nex.toa.io
32
+ content-type: application/yaml
33
+ traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
34
+
35
+ title: Hello
36
+ volume: 1.5
37
+ """
38
+ Then the following reply is sent:
39
+ """
40
+ 201 Created
41
+ ray: 0af7651916cd43dd8448eb211c80319c
42
+ """
43
+
44
+ Scenario: Trace is continued from the `ray` request header
45
+ When the following request is received:
46
+ """
47
+ POST /pots/ HTTP/1.1
48
+ host: nex.toa.io
49
+ content-type: application/yaml
50
+ ray: 4bf92f3577b34da6a3ce929d0e0e4736
51
+
52
+ title: Hello
53
+ volume: 1.5
54
+ """
55
+ Then the following reply is sent:
56
+ """
57
+ 201 Created
58
+ ray: 4bf92f3577b34da6a3ce929d0e0e4736
59
+ """
60
+
61
+ Scenario: Trace spans remote calls, storage and events
62
+ Given the `identity.basic` database is empty
63
+ And the `pricing` is running
64
+ And the `orders` is running with the following manifest:
65
+ """yaml
66
+ exposition:
67
+ /:id:
68
+ auth:id: id
69
+ POST: create
70
+ """
71
+ And the `audit` is running
72
+ And the `notify` is running
73
+ When the following request is received:
74
+ """
75
+ POST /identity/ HTTP/1.1
76
+ host: nex.toa.io
77
+ authorization: Basic dXNlcjpwYXNzMTIzNA==
78
+ accept: application/yaml
79
+ """
80
+ Then the following reply is sent:
81
+ """
82
+ 201 Created
83
+ authorization: Token ${{ token }}
84
+
85
+ id: ${{ id }}
86
+ roles: []
87
+ """
88
+ When the following request is received:
89
+ """
90
+ POST /orders/${{ id }}/ HTTP/1.1
91
+ host: nex.toa.io
92
+ authorization: Token ${{ token }}
93
+ content-type: application/yaml
94
+
95
+ title: Kettle
96
+ volume: 2
97
+ """
98
+ Then the following reply is sent:
99
+ """
100
+ 201 Created
101
+ ray:
102
+ """
103
+
104
+ Scenario: Invalid `traceparent` starts a new trace
105
+ When the following request is received:
106
+ """
107
+ POST /pots/ HTTP/1.1
108
+ host: nex.toa.io
109
+ content-type: application/yaml
110
+ traceparent: not-a-valid-traceparent
111
+
112
+ title: Hello
113
+ volume: 1.5
114
+ """
115
+ Then the following reply is sent:
116
+ """
117
+ 201 Created
118
+ ray:
119
+ """
120
+ And the reply does not contain:
121
+ """
122
+ ray: 0af7651916cd43dd8448eb211c80319c
123
+ """
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.exposition",
3
- "version": "1.0.0-alpha.245",
3
+ "version": "1.0.0-alpha.246",
4
4
  "description": "Toa Exposition",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -19,20 +19,20 @@
19
19
  "dependencies": {
20
20
  "@aws-sdk/protocol-http": "3.370.0",
21
21
  "@simplewebauthn/server": "13.1.1",
22
- "@toa.io/core": "1.0.0-alpha.243",
22
+ "@toa.io/core": "1.0.0-alpha.246",
23
23
  "@toa.io/generic": "1.0.0-alpha.225",
24
- "@toa.io/schemas": "1.0.0-alpha.225",
24
+ "@toa.io/schemas": "1.0.0-alpha.246",
25
25
  "bcryptjs": "2.4.3",
26
26
  "content-type": "1.0.5",
27
27
  "error-value": "0.4.2",
28
28
  "jose": "5.10.0",
29
- "js-yaml": "4.3.0",
29
+ "js-yaml": "4.3.1",
30
30
  "lru-cache": "11.2.7",
31
31
  "matchacho": "0.3.5",
32
32
  "minimatch": "10.2.4",
33
33
  "msgpackr": "1.11.9",
34
34
  "negotiator": "1.0.0",
35
- "openspan": "1.0.0-alpha.173",
35
+ "openspan": "1.0.0-alpha.246",
36
36
  "paseto": "3.1.4"
37
37
  },
38
38
  "scripts": {
@@ -64,5 +64,5 @@
64
64
  },
65
65
  "testEnvironment": "node"
66
66
  },
67
- "gitHead": "053dee53258c9adf64cb038fe638b2badd75ad09"
67
+ "gitHead": "d6ee2a7246c64a44c50c28fadcfd1e27aaf137cb"
68
68
  }
package/readme.md CHANGED
@@ -136,7 +136,13 @@ exposition:
136
136
  | `class` | Ingress class. |
137
137
  | `annotations` | Ingress annotations. |
138
138
  | `debug` | Output server errors. Default `false`. |
139
- | `trace` | Output [server timing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing). Default `false`. |
139
+
140
+ ### Observability
141
+
142
+ Each response contains a `ray` header with the trace ID of the request, and
143
+ [server timing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing) headers.
144
+
145
+ Incoming trace context is continued, see [Tracing](documentation/tracing.md).
140
146
 
141
147
  ### Context resources
142
148
 
@@ -9,5 +9,4 @@ resources:
9
9
  memory: *constraint
10
10
  annotations: <string>
11
11
  debug: boolean
12
- trace: boolean
13
12
  /: ~
@@ -6,6 +6,5 @@ export interface Annotation {
6
6
  resources?: Resources
7
7
  annotations?: Record<string, string>
8
8
  debug?: boolean
9
- trace?: boolean
10
9
  '/'?: object // parsed and validated by RTD.syntax.parse
11
10
  }
@@ -1,3 +1,4 @@
1
+ import { console, type SpanOptions } from 'openspan'
1
2
  import type { Context, OutgoingMessage } from './HTTP'
2
3
  import type { Remotes } from './Remotes'
3
4
  import type { Output } from './io'
@@ -17,7 +18,8 @@ export class Directives implements RTD.Directives {
17
18
  if (set.family.preflight === undefined)
18
19
  continue
19
20
 
20
- const out = await set.family.preflight(set.directives, context, parameters)
21
+ const out = await console.span(options(set, 'preflight'),
22
+ async () => await set.family.preflight!(set.directives, context, parameters))
21
23
 
22
24
  if (out === null)
23
25
  continue
@@ -34,7 +36,8 @@ export class Directives implements RTD.Directives {
34
36
  public async settle (context: Context, response: OutgoingMessage): Promise<void> {
35
37
  for (const set of this.sets)
36
38
  if (set.family.settle !== undefined)
37
- await set.family.settle(set.directives, context, response)
39
+ await console.span(options(set, 'settle'),
40
+ async () => await set.family.settle!(set.directives, context, response))
38
41
  }
39
42
 
40
43
  public dispose (): void {
@@ -67,6 +70,8 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
67
70
  declarations.sort((a, b) =>
68
71
  (mandatory.has(b.family) ? 1 : 0) - (mandatory.has(a.family) ? 1 : 0))
69
72
 
73
+ const names: Record<string, string[]> = {}
74
+
70
75
  for (const declaration of declarations) {
71
76
  const family = this.families[declaration.family]
72
77
 
@@ -77,6 +82,8 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
77
82
 
78
83
  groups[family.name] ??= []
79
84
  groups[family.name].push(directive)
85
+ names[family.name] ??= []
86
+ names[family.name].push(`${declaration.family}:${declaration.name}`)
80
87
  mandatory.delete(family.name)
81
88
  }
82
89
 
@@ -85,13 +92,15 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
85
92
  for (const family of mandatory)
86
93
  sets.push({
87
94
  family: this.families[family],
88
- directives: []
95
+ directives: [],
96
+ names: []
89
97
  })
90
98
 
91
99
  for (const [family, directives] of Object.entries(groups))
92
100
  sets.push({
93
101
  family: this.families[family],
94
- directives
102
+ directives,
103
+ names: names[family]
95
104
  })
96
105
 
97
106
  const directives = new Directives(sets)
@@ -107,6 +116,15 @@ export class DirectivesFactory implements RTD.DirectiveFactory {
107
116
  }
108
117
  }
109
118
 
119
+ function options (set: RTD.DirectiveSet, stage: 'preflight' | 'settle'): SpanOptions {
120
+ const options: SpanOptions = { name: `${set.family.name} ${stage}` }
121
+
122
+ if (set.names !== undefined && set.names.length > 0)
123
+ options.attributes = { directives: Array.from(new Set(set.names)).join(' ') }
124
+
125
+ return options
126
+ }
127
+
110
128
  export const shortcuts: RTD.syntax.Shortcuts = new Map([
111
129
  ['anonymous', 'auth:anonymous'],
112
130
  ['anyone', 'auth:anyone'],
package/source/Factory.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
  import { createHash } from 'node:crypto'
3
- import { console } from 'openspan'
3
+ import { console, traces, type LevelName, type TracesOptions } from 'openspan'
4
4
  import { decode } from '@toa.io/generic'
5
5
  import { Tenant } from './Tenant'
6
6
  import { Gateway } from './Gateway'
@@ -17,7 +17,6 @@ import type { Branch } from './Branch'
17
17
  import type { syntax } from './RTD'
18
18
  import type { Broadcast } from './Gateway'
19
19
  import type { Connector, Locator, extensions } from '@toa.io/core'
20
- import type { Channel } from 'openspan'
21
20
 
22
21
  export class Factory implements extensions.Factory {
23
22
  private readonly boot: Bootloader
@@ -72,13 +71,18 @@ export class Factory implements extensions.Factory {
72
71
 
73
72
  const CHANNEL = 'exposition'
74
73
  const LOGS_PREFIX = 'TOA_TELEMETRY_LOGS'
74
+ const TRACES_ENV = 'TOA_TELEMETRY_TRACES'
75
75
 
76
76
  function configureLogs (): void {
77
77
  const globEnv = process.env[LOGS_PREFIX]
78
- const level: Channel = process.env.TOA_DEV === '1' ? 'debug' : 'info'
79
- const options = globEnv === undefined ? { level } : decode<{ level?: Channel }>(globEnv)
78
+ const level: LevelName = process.env.TOA_DEV === '1' ? 'trace' : 'info'
79
+ const options = globEnv === undefined ? { level } : decode<{ level?: LevelName }>(globEnv)
80
80
 
81
81
  console.configure({ level: options.level ?? level })
82
+
83
+ const tracesEnv = process.env[TRACES_ENV]
84
+
85
+ traces(tracesEnv === undefined ? {} : decode<TracesOptions>(tracesEnv))
82
86
  }
83
87
 
84
88
  // eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -31,7 +31,7 @@ export class Context {
31
31
 
32
32
  this.id = crypto.randomUUID()
33
33
  this.url = new URL(request.url, `https://${request.headers.host}`)
34
- this.timing = new Timing(properties.trace)
34
+ this.timing = new Timing()
35
35
  this.debug = properties.debug
36
36
  this.log(request)
37
37
 
@@ -95,7 +95,6 @@ interface Pipelines {
95
95
 
96
96
  interface Properties {
97
97
  debug: boolean
98
- trace: boolean
99
98
  }
100
99
 
101
100
  const SUBTYPE = /^(?<type>\w{1,32})\/(vnd\.toa\.(?<subtype>\S{1,32})\+)(?<suffix>\S{1,32})$/