@toa.io/storages.mongodb 1.0.0-alpha.243 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.243",
3
+ "version": "1.0.0-alpha.246",
4
4
  "description": "Toa MongoDB Storage Connector",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -20,12 +20,12 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@toa.io/conveyor": "1.0.0-alpha.225",
23
- "@toa.io/core": "1.0.0-alpha.243",
23
+ "@toa.io/core": "1.0.0-alpha.246",
24
24
  "@toa.io/generic": "1.0.0-alpha.225",
25
- "@toa.io/pointer": "1.0.0-alpha.225",
25
+ "@toa.io/pointer": "1.0.0-alpha.246",
26
26
  "mongodb": "7.2.0",
27
- "openspan": "1.0.0-alpha.173",
27
+ "openspan": "1.0.0-alpha.246",
28
28
  "saslprep": "1.0.3"
29
29
  },
30
- "gitHead": "743e8060d35ca750f70487c04aef9698591c5edd"
30
+ "gitHead": "d6ee2a7246c64a44c50c28fadcfd1e27aaf137cb"
31
31
  }
package/readme.md ADDED
@@ -0,0 +1,16 @@
1
+ # MongoDB Storage
2
+
3
+ ## Tracing
4
+
5
+ Commands are recorded as `client` spans within the trace of the current invocation,
6
+ using the [driver command monitoring events](https://www.mongodb.com/docs/drivers/node/current/monitoring/command-monitoring/).
7
+
8
+ Spans are named `{command} {collection}` and carry `db.*` attributes following the
9
+ [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/mongodb/):
10
+ `db.system`, `db.namespace`, `db.collection.name`, `db.operation.name`.
11
+
12
+ Commands executed outside of a sampled trace context (e.g. index management on startup)
13
+ and internal driver commands (`hello`, `ping`, authentication) are not recorded.
14
+
15
+ Monitoring is client-side only and does not affect the MongoDB server. Span recording
16
+ adds no waiting to the query path: exporting is buffered and happens in the background.
package/src/client.js CHANGED
@@ -11,6 +11,7 @@ const { Connector } = require('@toa.io/core')
11
11
  const { resolve } = require('@toa.io/pointer')
12
12
  const { ID } = require('./deployment')
13
13
  const { MongoClient } = require('mongodb')
14
+ const { monitor } = require('./monitoring')
14
15
 
15
16
  /**
16
17
  * @type {Record<string, Promise<Instance>>}
@@ -112,6 +113,8 @@ class Client extends Connector {
112
113
  const client = new MongoClient(urls.join(','), OPTIONS)
113
114
  const hosts = urls.map((str) => new URL(str).host)
114
115
 
116
+ monitor(client)
117
+
115
118
  console.info('Connecting to MongoDB', { address: hosts.join(', ') })
116
119
 
117
120
  await client.connect()
@@ -156,7 +159,8 @@ function getKey (db, urls) {
156
159
  }
157
160
 
158
161
  const OPTIONS = {
159
- ignoreUndefined: true
162
+ ignoreUndefined: true,
163
+ monitorCommands: true
160
164
  }
161
165
 
162
166
  const ALREADY_EXISTS = 48
@@ -0,0 +1,87 @@
1
+ 'use strict'
2
+
3
+ const { create, current, record } = require('openspan')
4
+
5
+ /**
6
+ * Records MongoDB commands as client spans using the driver command monitoring events.
7
+ * Requires the client to be created with `monitorCommands: true`.
8
+ *
9
+ * Commands executed outside of a sampled trace context are not recorded.
10
+ *
11
+ * @param {import('mongodb').MongoClient} client
12
+ */
13
+ function monitor (client) {
14
+ /** @type {Map<number, { context: object, time: number, attributes: object }>} */
15
+ const pending = new Map()
16
+
17
+ client.on('commandStarted', (event) => {
18
+ if (IGNORE.has(event.commandName))
19
+ return
20
+
21
+ const parent = current()
22
+
23
+ if (parent === undefined || !parent.sampled)
24
+ return
25
+
26
+ if (pending.size >= CAPACITY) // should never happen
27
+ pending.delete(pending.keys().next().value)
28
+
29
+ // https://opentelemetry.io/docs/specs/semconv/database/mongodb/
30
+ const attributes = {
31
+ 'db.system': 'mongodb',
32
+ 'db.namespace': event.databaseName,
33
+ 'db.operation.name': event.commandName
34
+ }
35
+
36
+ const collection = event.command?.[event.commandName]
37
+
38
+ if (typeof collection === 'string')
39
+ attributes['db.collection.name'] = collection
40
+
41
+ pending.set(event.requestId, {
42
+ context: create(parent),
43
+ time: Date.now(),
44
+ attributes
45
+ })
46
+ })
47
+
48
+ client.on('commandSucceeded', (event) => complete(event))
49
+ client.on('commandFailed', (event) => complete(event, 'error'))
50
+
51
+ function complete (event, status) {
52
+ const started = pending.get(event.requestId)
53
+
54
+ if (started === undefined)
55
+ return
56
+
57
+ pending.delete(event.requestId)
58
+
59
+ const collection = started.attributes['db.collection.name']
60
+
61
+ const span = {
62
+ name: collection === undefined
63
+ ? event.commandName
64
+ : `${event.commandName} ${collection}`,
65
+ traceId: started.context.traceId,
66
+ spanId: started.context.spanId,
67
+ parentId: started.context.parentId,
68
+ kind: 'client',
69
+ time: started.time,
70
+ duration: event.duration,
71
+ attributes: started.attributes,
72
+ service: started.context.service
73
+ }
74
+
75
+ if (status !== undefined)
76
+ span.status = status
77
+
78
+ record(span)
79
+ }
80
+ }
81
+
82
+ // internal commands, including authentication and connection management
83
+ const IGNORE = new Set(['hello', 'ismaster', 'ping', 'endSessions', 'saslStart', 'saslContinue'])
84
+
85
+ const CAPACITY = 1024
86
+
87
+ exports.monitor = monitor
@@ -0,0 +1,99 @@
1
+ 'use strict'
2
+
3
+ const { EventEmitter } = require('node:events')
4
+ const { create, exporting, run, traces } = require('openspan')
5
+ const { monitor } = require('../src/monitoring')
6
+
7
+ let client
8
+ let spans
9
+
10
+ beforeEach(() => {
11
+ client = new EventEmitter()
12
+ spans = []
13
+
14
+ exporting([{ export: (span) => spans.push(span) }])
15
+ monitor(client)
16
+ })
17
+
18
+ afterEach(() => {
19
+ traces()
20
+ })
21
+
22
+ const started = {
23
+ requestId: 1,
24
+ commandName: 'find',
25
+ databaseName: 'toa-dev',
26
+ command: { find: 'pots', filter: {} }
27
+ }
28
+
29
+ const succeeded = { requestId: 1, commandName: 'find', duration: 1.5 }
30
+ const failed = { requestId: 1, commandName: 'find', duration: 0.5, failure: 'boom' }
31
+
32
+ it('should record a command as a client span', () => {
33
+ const context = create()
34
+
35
+ run(context, () => client.emit('commandStarted', started))
36
+ client.emit('commandSucceeded', succeeded)
37
+
38
+ expect(spans).toHaveLength(1)
39
+
40
+ expect(spans[0]).toMatchObject({
41
+ name: 'find pots',
42
+ traceId: context.traceId,
43
+ parentId: context.spanId,
44
+ kind: 'client',
45
+ duration: 1.5,
46
+ attributes: {
47
+ 'db.system': 'mongodb',
48
+ 'db.namespace': 'toa-dev',
49
+ 'db.operation.name': 'find',
50
+ 'db.collection.name': 'pots'
51
+ }
52
+ })
53
+
54
+ expect(spans[0].status).toBeUndefined()
55
+ })
56
+
57
+ it('should record failed commands with error status', () => {
58
+ run(create(), () => client.emit('commandStarted', started))
59
+ client.emit('commandFailed', failed)
60
+
61
+ expect(spans).toHaveLength(1)
62
+ expect(spans[0].status).toBe('error')
63
+ })
64
+
65
+ it('should not record commands outside of a trace context', () => {
66
+ client.emit('commandStarted', started)
67
+ client.emit('commandSucceeded', succeeded)
68
+
69
+ expect(spans).toHaveLength(0)
70
+ })
71
+
72
+ it('should not record commands of unsampled traces', () => {
73
+ const context = { ...create(), sampled: false }
74
+
75
+ run(context, () => client.emit('commandStarted', started))
76
+ client.emit('commandSucceeded', succeeded)
77
+
78
+ expect(spans).toHaveLength(0)
79
+ })
80
+
81
+ it('should ignore internal commands', () => {
82
+ run(create(), () => {
83
+ client.emit('commandStarted', { ...started, commandName: 'hello', command: { hello: 1 } })
84
+ })
85
+
86
+ client.emit('commandSucceeded', { ...succeeded, commandName: 'hello' })
87
+
88
+ expect(spans).toHaveLength(0)
89
+ })
90
+
91
+ it('should name spans without a collection by the command', () => {
92
+ const start = { ...started, commandName: 'listIndexes', command: { listIndexes: 1 } }
93
+
94
+ run(create(), () => client.emit('commandStarted', start))
95
+ client.emit('commandSucceeded', { ...succeeded, commandName: 'listIndexes' })
96
+
97
+ expect(spans).toHaveLength(1)
98
+ expect(spans[0].name).toBe('listIndexes')
99
+ })