graftcode-instrumentation 0.0.0-stage → 1.0.3

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/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # graftcode-instrumentation (Node.js)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ OpenTelemetry instrumentation for Graftcode (Hypertube) calls: client and server spans, W3C trace context propagation, and Hypertube log forwarding.
4
+
5
+ Hypertube exposes a **broker** (`HypertubeBroker.attach` / `detach`); nothing is monkey-patched. Enabling attaches a broker listener and a log listener; disabling removes them.
6
+
7
+ ## Usage
8
+
9
+ Register like any other OpenTelemetry instrumentation:
10
+
11
+ ```js
12
+ import { registerInstrumentations } from '@opentelemetry/instrumentation'
13
+ import { GraftcodeInstrumentation } from 'graftcode-instrumentation'
14
+
15
+ registerInstrumentations({ instrumentations: [new GraftcodeInstrumentation()] })
16
+ ```
17
+
18
+ ## Configuration (`GraftcodeInstrumentationConfig`)
19
+
20
+ | Option | Description |
21
+ |--------|-------------|
22
+ | `enabled` | Default `true`. |
23
+ | `captureBodies` | Record redacted request and response payloads on spans. Defaults to env `HYPERTUBE_TELEMETRY_CAPTURE_BODIES`, otherwise `false`. |
24
+ | `maxBodyLength` | Characters kept per payload, 256–32768. Defaults to `HYPERTUBE_TELEMETRY_BODY_MAX_CHARS`, otherwise `8192`. |
25
+ | `propagateContext` | Send W3C trace context with outgoing calls. Defaults to `HYPERTUBE_TELEMETRY_AUTO_INJECT`, otherwise `true`. |
26
+ | `logLevel` | Lowest Hypertube log level forwarded to OpenTelemetry logs (`trace`, `debug`, `info`, `warning`, `error`, `critical`, `off`). Default `info`. |
27
+ | `ignore` | `(operation) => boolean` — return `true` to skip tracing an operation. |
28
+ | `libraries` | Names the library behind a connection for the `Graft <library> <channel>` dependency name. Map from connection address (e.g. `ws://localhost:9180/ws`) to library name, or a function `(target) => string \| undefined`. Node.js types carry no namespace; without this, the caller falls back to `Graft <runtime> <channel>` unless the call loads the library. |
29
+
30
+ ## Span model
31
+
32
+ - **Caller:** span name `Graft <library> [version] <channel>` (or runtime fallback), kind `INTERNAL`, attributes `graft.dependency.type` = `Graft`, `graft.dependency.target`, plus Hypertube operation metadata.
33
+ - **Callee:** span name `Service.method` (or `method` for globals), kind `INTERNAL`.
34
+ - Remote failures that return an `Exception` command mark the span failed from that response (not only thrown errors).
35
+
36
+ ## Trace context
37
+
38
+ - Outgoing: W3C `traceparent` / `tracestate` via `callContext.outgoingHeaders`, plus `traceid` when sampled.
39
+ - Incoming: prefers W3C headers; a Graftcode `traceid` header alone still joins that trace under a synthetic remote parent.
40
+ - A `traceparent` already on the connection is not overridden.
41
+
42
+ ## Library catalog
43
+
44
+ `LibraryCatalog.record` remembers libraries loaded on a connection (`LoadLibrary`) so later calls can be attributed. `LibraryCatalog.describeTarget` formats `<library> [version] <channel>`. Version is read from local `package.json` when available. Package folder naming rules apply for `package.json`, `index.js`, `main.js`, etc.
45
+
46
+ ## Body capture
47
+
48
+ When enabled, request/response bodies are JSON-like renderings with redaction and truncation (`hypertube.request.body`, `hypertube.response.body`, `.truncated` when clipped).
49
+
50
+ ## Operations (`resolveOperation`)
51
+
52
+ Maps Hypertube commands to `GraftOperation`: `name` (`Service.method` or `method`), `service`, `method`, `commandType`. Skips non-member / control commands without a resolvable method name.
53
+
54
+ ## Log bridge
55
+
56
+ Forwards Hypertube log records to the OpenTelemetry Logs API, correlated with the active span, respecting configured minimum level and redaction on errors.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
- "name": "graftcode-instrumentation",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "graftcode-instrumentation",
3
+ "version": "1.0.3",
4
+ "description": "OpenTelemetry instrumentation for Graftcode (Hypertube) calls in Node.js: client and server spans, W3C trace context propagation and Hypertube log forwarding.",
5
+ "license": "ISC",
6
+ "author": "Graftcode S.A.",
7
+ "type": "module",
8
+ "main": "./src/index.js",
9
+ "types": "./src/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.d.ts",
13
+ "default": "./src/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "README.md",
18
+ "src"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test \"test/**/*.test.js\""
25
+ },
26
+ "dependencies": {
27
+ "@opentelemetry/instrumentation": "^0.221.0"
28
+ },
29
+ "peerDependencies": {
30
+ "@opentelemetry/api": "^1.9.0",
31
+ "hypertube-nodejs-sdk": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@opentelemetry/api": "^1.9.1",
35
+ "@opentelemetry/sdk-logs": "^0.221.0",
36
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
37
+ "@opentelemetry/sdk-trace-node": "^2.10.0",
38
+ "hypertube-nodejs-sdk": "file:../../../../NewHT/HYPERTUBE/src/js/hypertube-nodejs-sdk"
39
+ }
40
+ }
@@ -0,0 +1,116 @@
1
+ import { createRequire } from 'node:module'
2
+ import { context } from '@opentelemetry/api'
3
+ import { InstrumentationBase } from '@opentelemetry/instrumentation'
4
+ import {
5
+ HypertubeBroker,
6
+ HypertubeLogger,
7
+ LogsCommandResolver,
8
+ LogsLevel,
9
+ LogsRedactor,
10
+ } from 'hypertube-nodejs-sdk/abstractions'
11
+ import { CallSpan, completeSpans } from './callSpan.js'
12
+ import { resolveInstrumentationOptions } from './graftcodeTelemetrySettings.js'
13
+
14
+ const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('../package.json')
15
+
16
+ export const INSTRUMENTATION_NAME = 'Graftcode'
17
+ export const LOGGER_NAME = 'Hypertube'
18
+
19
+ const SEVERITIES = [
20
+ [1, 'TRACE'],
21
+ [5, 'DEBUG'],
22
+ [9, 'INFO'],
23
+ [13, 'WARN'],
24
+ [17, 'ERROR'],
25
+ [21, 'FATAL'],
26
+ ]
27
+
28
+ export class GraftcodeInstrumentation extends InstrumentationBase {
29
+ constructor(config = {}) {
30
+ super(INSTRUMENTATION_NAME, PACKAGE_VERSION, config)
31
+ }
32
+
33
+ init() {
34
+ return []
35
+ }
36
+
37
+ enable() {
38
+ if (this._detachFromHypertube) {
39
+ return
40
+ }
41
+
42
+ const getSettings = () => this._currentSettings()
43
+ const listener = {
44
+ onExecute: (callContext, command, connectionData) => {
45
+ const reported = LogsCommandResolver.resolveForInvoke(command)
46
+ CallSpan.startInvoke(this.tracer, callContext, reported, connectionData, getSettings())
47
+ },
48
+ onResponse: (callContext, response) => {
49
+ completeSpans(callContext, response, null)
50
+ },
51
+ onInvocationError: (callContext, error) => {
52
+ completeSpans(callContext, null, error)
53
+ },
54
+ onProcess: (callContext, command) => {
55
+ const headers = {}
56
+ const reported = LogsCommandResolver.resolveForHandle(command, headers)
57
+ CallSpan.startHandle(this.tracer, callContext, reported, headers, getSettings())
58
+ },
59
+ onProcessed: (callContext, response) => {
60
+ completeSpans(callContext, response, null)
61
+ },
62
+ onHandlingError: (callContext, error) => {
63
+ completeSpans(callContext, null, error)
64
+ },
65
+ }
66
+
67
+ const logBridge = {
68
+ isEnabled: (level) => level < LogsLevel.Off && level >= getSettings().logLevel,
69
+
70
+ log: (record) => {
71
+ const [severityNumber, severityText] = SEVERITIES[record.level] ?? SEVERITIES[LogsLevel.Info]
72
+ const attributes = { ...record.properties }
73
+
74
+ if (record.error !== undefined && record.error !== null) {
75
+ const error = record.error
76
+ attributes['exception.type'] = error instanceof Error ? error.name : typeof error
77
+ attributes['exception.message'] = String(
78
+ LogsRedactor.redactValue(error instanceof Error ? error.message : String(error)) ?? ''
79
+ )
80
+ if (error instanceof Error && error.stack) {
81
+ attributes['exception.stacktrace'] = LogsRedactor.redactBody(error.stack)
82
+ }
83
+ }
84
+
85
+ this.logger.emit({
86
+ severityNumber,
87
+ severityText,
88
+ body: record.message,
89
+ attributes,
90
+ context: context.active(),
91
+ })
92
+ },
93
+ }
94
+
95
+ const detachBroker = HypertubeBroker.attach(listener)
96
+ const detachLogs = HypertubeLogger.attach(logBridge)
97
+ this._detachFromHypertube = () => {
98
+ detachBroker()
99
+ detachLogs()
100
+ }
101
+ }
102
+
103
+ disable() {
104
+ this._detachFromHypertube?.()
105
+ this._detachFromHypertube = undefined
106
+ }
107
+
108
+ _currentSettings() {
109
+ const config = this.getConfig()
110
+ if (config !== this._settingsSource || !this._settings) {
111
+ this._settingsSource = config
112
+ this._settings = resolveInstrumentationOptions(config)
113
+ }
114
+ return this._settings
115
+ }
116
+ }
@@ -0,0 +1,109 @@
1
+ import { LogsRedactor, commandTypeName, isCommand, runtimeNameOf } from 'hypertube-nodejs-sdk/abstractions'
2
+
3
+ const TRUNCATION_MARKER = '"__truncated__"'
4
+ const MAX_DEPTH = 24
5
+ const CLOSING_COST_PER_LEVEL = 18
6
+ const SCALAR_RESERVE = 32
7
+
8
+ export function renderBody(command, maxLength) {
9
+ if (!isCommand(command)) {
10
+ return undefined
11
+ }
12
+
13
+ const writer = { out: '', max: maxLength, truncated: false }
14
+ writeCommand(writer, command, 0)
15
+ return { body: LogsRedactor.redactBody(writer.out), truncated: writer.truncated }
16
+ }
17
+
18
+ function writeCommand(writer, command, depth) {
19
+ writer.out += `{"runtime":"${runtimeNameOf(command.runtimeName)}","type":"${commandTypeName(command.commandType)}","payload":[`
20
+
21
+ const budget = Math.max(0, writer.max - (depth + 1) * CLOSING_COST_PER_LEVEL - SCALAR_RESERVE)
22
+ const payload = command.payload
23
+ let complete = true
24
+ let hasContent = false
25
+
26
+ for (let i = 0; i < payload.length; i++) {
27
+ if (writer.out.length >= budget) {
28
+ writer.truncated = true
29
+ complete = false
30
+ break
31
+ }
32
+ if (hasContent) {
33
+ writer.out += ','
34
+ }
35
+ hasContent = true
36
+
37
+ const followsSensitiveKey =
38
+ i > 0 && typeof payload[i - 1] === 'string' && LogsRedactor.isSensitiveKey(payload[i - 1])
39
+ if (!writeValue(writer, payload[i], depth + 1, budget, followsSensitiveKey)) {
40
+ complete = false
41
+ break
42
+ }
43
+ }
44
+
45
+ if (!complete) {
46
+ writer.out += hasContent ? `,${TRUNCATION_MARKER}` : TRUNCATION_MARKER
47
+ }
48
+ writer.out += ']}'
49
+ return complete
50
+ }
51
+
52
+ function writeValue(writer, value, depth, budget, forceRedact) {
53
+ if (value === null || value === undefined) {
54
+ writer.out += 'null'
55
+ return true
56
+ }
57
+ if (depth >= MAX_DEPTH) {
58
+ writer.truncated = true
59
+ writer.out += TRUNCATION_MARKER
60
+ return true
61
+ }
62
+ if (isCommand(value)) {
63
+ return writeCommand(writer, value, depth)
64
+ }
65
+ if (forceRedact) {
66
+ return writeString(writer, LogsRedactor.Placeholder, budget)
67
+ }
68
+
69
+ switch (typeof value) {
70
+ case 'string':
71
+ return writeString(writer, LogsRedactor.redactValue(value), budget)
72
+ case 'boolean':
73
+ writer.out += String(value)
74
+ return true
75
+ case 'number':
76
+ writer.out += Number.isFinite(value) ? String(value) : `"${value}"`
77
+ return true
78
+ case 'bigint':
79
+ writer.out += value.toString()
80
+ return true
81
+ }
82
+
83
+ if (value instanceof Uint8Array) {
84
+ writer.out += `"[bytes:${value.length}]"`
85
+ return true
86
+ }
87
+ return writeString(writer, LogsRedactor.redactValue(format(value)), budget)
88
+ }
89
+
90
+ function writeString(writer, value, budget) {
91
+ const encoded = JSON.stringify(value)
92
+ if (writer.out.length + encoded.length <= budget) {
93
+ writer.out += encoded
94
+ return true
95
+ }
96
+
97
+ writer.truncated = true
98
+ const room = Math.max(0, budget - writer.out.length - 2)
99
+ writer.out += JSON.stringify(value.slice(0, room))
100
+ return false
101
+ }
102
+
103
+ function format(value) {
104
+ try {
105
+ return String(value)
106
+ } catch {
107
+ return `[unrenderable:${Object.prototype.toString.call(value)}]`
108
+ }
109
+ }
@@ -0,0 +1,257 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { SpanKind, SpanStatusCode, TraceFlags, context, isSpanContextValid, propagation, trace } from '@opentelemetry/api'
3
+ import {
4
+ CommandType,
5
+ LogsRedactor,
6
+ commandTypeName,
7
+ connectionTypeName,
8
+ isCommand,
9
+ runtimeNameOf,
10
+ } from 'hypertube-nodejs-sdk/abstractions'
11
+ import { renderBody } from './bodyCapture.js'
12
+ import * as GraftcodeTelemetrySettings from './graftcodeTelemetrySettings.js'
13
+ import { describeTarget, record } from './libraryCatalog.js'
14
+ import { resolveOperation } from './resolveOperation.js'
15
+
16
+ export const DEPENDENCY_TYPE = 'graft.dependency.type'
17
+ export const DEPENDENCY_TARGET = 'graft.dependency.target'
18
+ export const BRIDGE_DEPENDENCY_TYPE = 'Graft'
19
+ export const REQUEST_BODY = 'hypertube.request.body'
20
+ export const RESPONSE_BODY = 'hypertube.response.body'
21
+ export const REQUEST_BODY_TRUNCATED = 'hypertube.request.body.truncated'
22
+ export const RESPONSE_BODY_TRUNCATED = 'hypertube.response.body.truncated'
23
+ export const TRACE_PARENT_HEADER = 'traceparent'
24
+ export const TRACE_STATE_HEADER = 'tracestate'
25
+ export const TRACE_ID_HEADER = 'traceid'
26
+
27
+ const SKIP_COMMAND_TYPES = new Set([
28
+ CommandType.PassByValue,
29
+ CommandType.RegisterForUpdate,
30
+ CommandType.ValueForUpdate,
31
+ CommandType.InvokeDelegate,
32
+ ])
33
+
34
+ export class CallSpan {
35
+ #span
36
+ #settings
37
+ #completed = false
38
+
39
+ constructor(span, settings) {
40
+ this.#span = span
41
+ this.#settings = settings
42
+ }
43
+
44
+ static startInvoke(tracer, callContext, command, connectionData, settings) {
45
+ const operation = tryResolveOperation(command, settings)
46
+ if (!operation) {
47
+ return null
48
+ }
49
+
50
+ record(command, connectionData)
51
+ const target = describeTarget(command, connectionData, settings.libraries, operation)
52
+ const parent = context.active()
53
+ const span = tracer.startSpan(
54
+ `Graft ${target}`,
55
+ {
56
+ kind: SpanKind.INTERNAL,
57
+ attributes: invokeAttributes(operation, command, connectionData, target),
58
+ },
59
+ parent
60
+ )
61
+ const callSpan = new CallSpan(span, settings)
62
+ callSpan.#pushScope(callContext, parent, span)
63
+ callSpan.capture(command, REQUEST_BODY, REQUEST_BODY_TRUNCATED)
64
+
65
+ if (GraftcodeTelemetrySettings.isAutoInjectEnabled() && !hasOwnTraceparent(connectionData)) {
66
+ inject(trace.setSpan(parent, span), callContext.outgoingHeaders)
67
+ }
68
+
69
+ return callSpan
70
+ }
71
+
72
+ static startHandle(tracer, callContext, command, headers, settings) {
73
+ const operation = tryResolveOperation(command, settings)
74
+ if (!operation) {
75
+ return null
76
+ }
77
+
78
+ const parent = extractParent(headers)
79
+ const span = tracer.startSpan(
80
+ operation.name,
81
+ { kind: SpanKind.INTERNAL, attributes: handleAttributes(operation, command) },
82
+ parent
83
+ )
84
+ const callSpan = new CallSpan(span, settings)
85
+ callSpan.#pushScope(callContext, parent, span)
86
+ callSpan.capture(command, REQUEST_BODY, REQUEST_BODY_TRUNCATED)
87
+ return callSpan
88
+ }
89
+
90
+ complete(response, error) {
91
+ if (this.#completed) {
92
+ return
93
+ }
94
+ this.#completed = true
95
+
96
+ if (response != null) {
97
+ this.capture(response, RESPONSE_BODY, RESPONSE_BODY_TRUNCATED)
98
+ }
99
+
100
+ if (error == null) {
101
+ if (isCommand(response) && response.commandType === CommandType.Exception) {
102
+ const name = typeof response.payload[2] === 'string' ? response.payload[2] : 'Exception'
103
+ const message = typeof response.payload[3] === 'string' ? response.payload[3] : ''
104
+ markFailed(this.#span, name, message)
105
+ } else {
106
+ this.#span.setStatus({ code: SpanStatusCode.OK })
107
+ }
108
+ } else {
109
+ const name = error instanceof Error ? error.name : typeof error
110
+ const message = error instanceof Error ? error.message : String(error)
111
+ const stack =
112
+ error instanceof Error && error.stack ? LogsRedactor.redactBody(error.stack) : undefined
113
+ markFailed(this.#span, name, message, stack)
114
+ }
115
+
116
+ this.#span.end()
117
+ }
118
+
119
+ capture(command, bodyKey, truncatedKey) {
120
+ if (!this.#span.isRecording() || !GraftcodeTelemetrySettings.isBodyCaptureEnabled()) {
121
+ return
122
+ }
123
+ const rendered = renderBody(command, GraftcodeTelemetrySettings.getBodyMaxCharacters())
124
+ if (!rendered) {
125
+ return
126
+ }
127
+ this.#span.setAttribute(bodyKey, rendered.body)
128
+ if (rendered.truncated) {
129
+ this.#span.setAttribute(truncatedKey, true)
130
+ }
131
+ }
132
+
133
+ #pushScope(callContext, parent, span) {
134
+ const active = trace.setSpan(parent, span)
135
+ callContext.items.push(this)
136
+ callContext.items.push({
137
+ wrap(run) {
138
+ return context.with(active, run)
139
+ },
140
+ })
141
+ }
142
+ }
143
+
144
+ function tryResolveOperation(command, settings) {
145
+ if (!isCommand(command) || SKIP_COMMAND_TYPES.has(command.commandType)) {
146
+ return undefined
147
+ }
148
+
149
+ const operation = resolveOperation(command)
150
+ if (!operation || settings.ignore?.(operation)) {
151
+ return undefined
152
+ }
153
+
154
+ if (operation.name.toLowerCase().endsWith('telemetrysetup.initialize')) {
155
+ return undefined
156
+ }
157
+
158
+ return operation
159
+ }
160
+
161
+ function invokeAttributes(operation, command, connectionData, target) {
162
+ const attributes = handleAttributes(operation, command)
163
+ const connectionType = safe(() => connectionData.connectionType)
164
+
165
+ attributes[DEPENDENCY_TYPE] = BRIDGE_DEPENDENCY_TYPE
166
+ attributes[DEPENDENCY_TARGET] = String(LogsRedactor.redactValue(target) ?? '')
167
+ attributes['hypertube.target'] = safe(() => LogsRedactor.redactTarget(connectionData.hostname)) ?? ''
168
+ if (typeof connectionType === 'number') {
169
+ attributes['hypertube.connection_type'] = connectionTypeName(connectionType)
170
+ }
171
+ return attributes
172
+ }
173
+
174
+ function handleAttributes(operation, command) {
175
+ return {
176
+ 'hypertube.runtime': runtimeNameOf(command.runtimeName),
177
+ 'hypertube.command_type': commandTypeName(command.commandType),
178
+ 'hypertube.operation': operation.name,
179
+ }
180
+ }
181
+
182
+ function markFailed(span, name, message, stack) {
183
+ const redacted = String(LogsRedactor.redactValue(message) ?? '')
184
+ span.recordException({ name, message: redacted, stack })
185
+ span.setAttribute('exception.type', name)
186
+ if (redacted) {
187
+ span.setAttribute('exception.message', redacted)
188
+ }
189
+ span.setStatus({ code: SpanStatusCode.ERROR, message: redacted })
190
+ }
191
+
192
+ function inject(active, headers) {
193
+ propagation.inject(active, headers)
194
+ const spanContext = trace.getSpanContext(active)
195
+ if (spanContext && isSpanContextValid(spanContext)) {
196
+ headers[TRACE_ID_HEADER] = spanContext.traceId
197
+ }
198
+ }
199
+
200
+ function extractParent(headers) {
201
+ const base = context.active()
202
+ const extracted = propagation.extract(base, headers)
203
+ const extractedSpan = trace.getSpanContext(extracted)
204
+ if (extractedSpan && isSpanContextValid(extractedSpan) && extractedSpan !== trace.getSpanContext(base)) {
205
+ return extracted
206
+ }
207
+
208
+ const traceId = readTraceId(headers[TRACE_ID_HEADER])
209
+ if (!traceId) {
210
+ return base
211
+ }
212
+ return trace.setSpanContext(base, {
213
+ traceId,
214
+ spanId: randomSpanId(),
215
+ traceFlags: TraceFlags.SAMPLED,
216
+ isRemote: true,
217
+ })
218
+ }
219
+
220
+ function readTraceId(raw) {
221
+ const hex = raw?.trim().replaceAll('-', '').toLowerCase()
222
+ return hex && /^[0-9a-f]{32}$/.test(hex) && !/^0+$/.test(hex) ? hex : undefined
223
+ }
224
+
225
+ function randomSpanId() {
226
+ let spanId
227
+ do {
228
+ spanId = randomBytes(8).toString('hex')
229
+ } while (/^0+$/.test(spanId))
230
+ return spanId
231
+ }
232
+
233
+ function hasOwnTraceparent(connectionData) {
234
+ try {
235
+ return TRACE_PARENT_HEADER in connectionData.getHeaders()
236
+ } catch {
237
+ return false
238
+ }
239
+ }
240
+
241
+ function safe(read) {
242
+ try {
243
+ return read()
244
+ } catch {
245
+ return undefined
246
+ }
247
+ }
248
+
249
+ export function completeSpans(callContext, response, error) {
250
+ for (let i = callContext.items.length - 1; i >= 0; i--) {
251
+ const item = callContext.items[i]
252
+ if (item instanceof CallSpan) {
253
+ item.complete(response, error)
254
+ callContext.items.splice(i, 1)
255
+ }
256
+ }
257
+ }
@@ -0,0 +1,77 @@
1
+ import { LogsLevel, parseLogsLevel } from 'hypertube-nodejs-sdk/abstractions'
2
+
3
+ export const MINIMUM_BODY_MAX_CHARS = 256
4
+ export const DEFAULT_BODY_MAX_CHARS = 8192
5
+ export const MAXIMUM_BODY_MAX_CHARS = 32768
6
+
7
+ let autoInject = readFlag('HYPERTUBE_TELEMETRY_AUTO_INJECT', true)
8
+ let captureBodies = readFlag('HYPERTUBE_TELEMETRY_CAPTURE_BODIES', false)
9
+ let bodyMaxCharacters = readBodyMaxChars()
10
+
11
+ export function isAutoInjectEnabled() {
12
+ return autoInject
13
+ }
14
+
15
+ export function setAutoInjectEnabled(value) {
16
+ autoInject = value
17
+ }
18
+
19
+ export function isBodyCaptureEnabled() {
20
+ return captureBodies
21
+ }
22
+
23
+ export function setBodyCaptureEnabled(value) {
24
+ captureBodies = value
25
+ }
26
+
27
+ export function getBodyMaxCharacters() {
28
+ return bodyMaxCharacters
29
+ }
30
+
31
+ export function setBodyMaxCharacters(value) {
32
+ bodyMaxCharacters = clampBodyMaxCharacters(value)
33
+ }
34
+
35
+ export function clampBodyMaxCharacters(value) {
36
+ if (value < MINIMUM_BODY_MAX_CHARS) {
37
+ return MINIMUM_BODY_MAX_CHARS
38
+ }
39
+ return Math.min(value, MAXIMUM_BODY_MAX_CHARS)
40
+ }
41
+
42
+ export function applyInstrumentationConfig(config) {
43
+ if (config.propagateContext !== undefined) {
44
+ setAutoInjectEnabled(config.propagateContext)
45
+ }
46
+ if (config.captureBodies !== undefined) {
47
+ setBodyCaptureEnabled(config.captureBodies)
48
+ }
49
+ if (config.maxBodyLength !== undefined) {
50
+ setBodyMaxCharacters(config.maxBodyLength)
51
+ }
52
+ }
53
+
54
+ export function resolveInstrumentationOptions(config) {
55
+ applyInstrumentationConfig(config)
56
+ return {
57
+ logLevel: parseLogsLevel(config.logLevel ?? 'info') ?? LogsLevel.Info,
58
+ ignore: typeof config.ignore === 'function' ? config.ignore : undefined,
59
+ libraries:
60
+ typeof config.libraries === 'function' || (config.libraries && typeof config.libraries === 'object')
61
+ ? config.libraries
62
+ : undefined,
63
+ }
64
+ }
65
+
66
+ function readBodyMaxChars() {
67
+ const value = Number.parseInt(process.env.HYPERTUBE_TELEMETRY_BODY_MAX_CHARS ?? '', 10)
68
+ return Number.isFinite(value) ? clampBodyMaxCharacters(value) : DEFAULT_BODY_MAX_CHARS
69
+ }
70
+
71
+ function readFlag(name, defaultValue) {
72
+ const value = process.env[name]?.trim().toLowerCase()
73
+ if (!value) {
74
+ return defaultValue
75
+ }
76
+ return value !== 'false' && value !== '0'
77
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { InstrumentationBase, InstrumentationConfig } from '@opentelemetry/instrumentation'
2
+
3
+ export interface GraftOperation {
4
+ name: string
5
+ service?: string
6
+ method: string
7
+ commandType: string
8
+ }
9
+
10
+ export interface LibraryTarget {
11
+ target: string
12
+ channel: string
13
+ runtime: string
14
+ service?: string
15
+ method: string
16
+ }
17
+
18
+ export type LibraryResolver = Record<string, string> | ((target: LibraryTarget) => string | undefined)
19
+
20
+ export interface GraftcodeInstrumentationConfig extends InstrumentationConfig {
21
+ captureBodies?: boolean
22
+ maxBodyLength?: number
23
+ propagateContext?: boolean
24
+ logLevel?: 'trace' | 'debug' | 'info' | 'warning' | 'error' | 'critical' | 'off'
25
+ ignore?: (operation: GraftOperation) => boolean
26
+ libraries?: LibraryResolver
27
+ }
28
+
29
+ export declare const INSTRUMENTATION_NAME: 'Graftcode'
30
+ export declare const LOGGER_NAME: 'Hypertube'
31
+
32
+ export declare class GraftcodeInstrumentation extends InstrumentationBase<GraftcodeInstrumentationConfig> {
33
+ constructor(config?: GraftcodeInstrumentationConfig)
34
+ protected init(): []
35
+ enable(): void
36
+ disable(): void
37
+ }
38
+
39
+ export declare function resolveOperation(command: unknown): GraftOperation | undefined
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { GraftcodeInstrumentation, INSTRUMENTATION_NAME, LOGGER_NAME } from './GraftcodeInstrumentation.js'
2
+ export { resolveOperation } from './resolveOperation.js'
@@ -0,0 +1,189 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import { basename, dirname, extname, join } from 'node:path'
3
+ import { CommandType, connectionTypeName, isCommand, runtimeNameOf } from 'hypertube-nodejs-sdk/abstractions'
4
+ import { resolveTargetTypeName } from './operationName.js'
5
+
6
+ const MAX_DEPTH = 32
7
+ const ENTRY_FILE_NAMES = new Set(['index', 'main'])
8
+
9
+ const librariesByConnection = new Map()
10
+ const versionByPath = new Map()
11
+
12
+ export function record(command, connectionData) {
13
+ for (const load of findCommands(command, CommandType.LoadLibrary, 0)) {
14
+ const path = libraryPath(load)
15
+ const name = path ? libraryNameFromPath(path) : undefined
16
+ if (!path || !name) {
17
+ continue
18
+ }
19
+
20
+ const key = connectionKey(connectionData)
21
+ let libraries = librariesByConnection.get(key)
22
+ if (!libraries) {
23
+ libraries = new Map()
24
+ librariesByConnection.set(key, libraries)
25
+ }
26
+ libraries.set(name.toLowerCase(), { name, version: readVersion(path) })
27
+ }
28
+ }
29
+
30
+ export function describeTarget(command, connectionData, configured, operation) {
31
+ const channel = channelName(connectionData)
32
+ const library = resolveLibrary(command, connectionData, operation, configured)
33
+ if (!library) {
34
+ return `${runtimeNameOf(command.runtimeName)} ${channel}`
35
+ }
36
+ return library.version ? `${library.name} ${library.version} ${channel}` : `${library.name} ${channel}`
37
+ }
38
+
39
+ export function channelName(connectionData) {
40
+ return typeof connectionData.connectionType === 'number'
41
+ ? connectionTypeName(connectionData.connectionType).toLowerCase()
42
+ : 'unknown'
43
+ }
44
+
45
+ export function resetLibraryCatalog() {
46
+ librariesByConnection.clear()
47
+ versionByPath.clear()
48
+ }
49
+
50
+ function resolveLibrary(command, connectionData, operation, configured) {
51
+ const named = configuredName(configured, connectionData, command, operation)
52
+ if (named) {
53
+ return lookup(connectionData, named) ?? { name: named, version: '' }
54
+ }
55
+
56
+ for (const load of findCommands(command, CommandType.LoadLibrary, 0)) {
57
+ const path = libraryPath(load)
58
+ const name = path ? libraryNameFromPath(path) : undefined
59
+ if (name) {
60
+ return lookup(connectionData, name) ?? { name, version: '' }
61
+ }
62
+ }
63
+
64
+ const libraries = librariesByConnection.get(connectionKey(connectionData))
65
+ const typeName = operation?.service ?? resolveTargetTypeName(command)
66
+ if (!typeName) {
67
+ return undefined
68
+ }
69
+
70
+ if (libraries) {
71
+ let best
72
+ for (const library of libraries.values()) {
73
+ if (isTypeOfLibrary(typeName, library.name) && (!best || library.name.length > best.name.length)) {
74
+ best = library
75
+ }
76
+ }
77
+ if (best) {
78
+ return best
79
+ }
80
+ }
81
+
82
+ const separator = typeName.indexOf('.')
83
+ if (separator > 0) {
84
+ return { name: typeName.slice(0, separator), version: '' }
85
+ }
86
+
87
+ if (libraries?.size === 1) {
88
+ return libraries.values().next().value
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ function configuredName(configured, connectionData, command, operation) {
94
+ if (!configured) {
95
+ return undefined
96
+ }
97
+
98
+ const target = connectionData.hostname ?? ''
99
+ let name
100
+ try {
101
+ name =
102
+ typeof configured === 'function'
103
+ ? configured({
104
+ target,
105
+ channel: channelName(connectionData),
106
+ runtime: runtimeNameOf(command.runtimeName),
107
+ service: operation?.service ?? resolveTargetTypeName(command),
108
+ method: operation?.method,
109
+ })
110
+ : (configured[target] ?? configured[target.replace(/\/+$/, '')])
111
+ } catch {
112
+ return undefined
113
+ }
114
+ return typeof name === 'string' && name.trim() ? name.trim() : undefined
115
+ }
116
+
117
+ function lookup(connectionData, name) {
118
+ return librariesByConnection.get(connectionKey(connectionData))?.get(name.toLowerCase())
119
+ }
120
+
121
+ function isTypeOfLibrary(typeName, libraryName) {
122
+ const type = typeName.toLowerCase()
123
+ const library = libraryName.toLowerCase()
124
+ return type === library || type.startsWith(`${library}.`)
125
+ }
126
+
127
+ function connectionKey(connectionData) {
128
+ return `${connectionData.connectionType}|${connectionData.hostname ?? ''}`
129
+ }
130
+
131
+ function findCommands(command, commandType, depth) {
132
+ if (depth > MAX_DEPTH || !isCommand(command)) {
133
+ return []
134
+ }
135
+
136
+ const matches = command.commandType === commandType ? [command] : []
137
+ for (const item of command.payload) {
138
+ matches.push(...findCommands(item, commandType, depth + 1))
139
+ }
140
+ return matches
141
+ }
142
+
143
+ function libraryPath(load) {
144
+ for (let i = load.payload.length - 1; i >= 0; i--) {
145
+ const item = load.payload[i]
146
+ const text = isCommand(item) && item.commandType === CommandType.Value ? item.payload[0] : item
147
+ if (typeof text === 'string' && text.trim()) {
148
+ return text.trim()
149
+ }
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ function libraryNameFromPath(path) {
155
+ const trimmed = path.replace(/[\\/]+$/, '')
156
+ const file = basename(trimmed)
157
+ if (file.toLowerCase() === 'package.json') {
158
+ return basename(dirname(trimmed)) || undefined
159
+ }
160
+
161
+ const name = file.slice(0, file.length - extname(file).length) || file
162
+ if (ENTRY_FILE_NAMES.has(name.toLowerCase()) && /\.[cm]?js$/i.test(file)) {
163
+ return basename(dirname(trimmed)) || name
164
+ }
165
+ return name || undefined
166
+ }
167
+
168
+ function readVersion(path) {
169
+ const cached = versionByPath.get(path)
170
+ if (cached !== undefined) {
171
+ return cached
172
+ }
173
+
174
+ let version = ''
175
+ try {
176
+ const trimmed = path.replace(/[\\/]+$/, '')
177
+ const packageJson = trimmed.toLowerCase().endsWith('package.json')
178
+ ? trimmed
179
+ : statSync(trimmed).isDirectory()
180
+ ? join(trimmed, 'package.json')
181
+ : join(dirname(trimmed), 'package.json')
182
+ const parsed = JSON.parse(readFileSync(packageJson, 'utf8'))
183
+ version = typeof parsed.version === 'string' ? parsed.version.trim() : ''
184
+ } catch {
185
+ version = ''
186
+ }
187
+ versionByPath.set(path, version)
188
+ return version
189
+ }
@@ -0,0 +1,139 @@
1
+ import { CommandType, isCommand } from 'hypertube-nodejs-sdk/abstractions'
2
+
3
+ const MAX_DEPTH = 32
4
+
5
+ export function isBlank(value) {
6
+ return value == null || String(value).trim() === ''
7
+ }
8
+
9
+ export function resolve(command) {
10
+ if (!isCommand(command)) {
11
+ return undefined
12
+ }
13
+
14
+ switch (command.commandType) {
15
+ case CommandType.InvokeStaticMethod:
16
+ case CommandType.InvokeInstanceMethod:
17
+ case CommandType.InvokeGenericStaticMethod:
18
+ case CommandType.InvokeGenericMethod:
19
+ case CommandType.GetStaticField:
20
+ case CommandType.SetStaticField:
21
+ case CommandType.GetInstanceField:
22
+ case CommandType.SetInstanceField:
23
+ case CommandType.GetStaticMethodAsDelegate:
24
+ case CommandType.GetInstanceMethodAsDelegate: {
25
+ const qualified = qualify(
26
+ resolveTypeNameCore(command.payload[0], 0),
27
+ resolveText(command.payload[1], 0)
28
+ )
29
+ return qualified ?? fallback(command)
30
+ }
31
+
32
+ case CommandType.CreateClassInstance: {
33
+ const createdType = resolveTypeNameCore(command.payload[0], 0)
34
+ return createdType == null ? fallback(command) : `${createdType}.ctor`
35
+ }
36
+
37
+ case CommandType.InvokeGlobalFunction:
38
+ case CommandType.GetGlobalField: {
39
+ const name = resolveText(command.payload[0], 0)
40
+ return name ?? fallback(command)
41
+ }
42
+
43
+ default:
44
+ return fallback(command)
45
+ }
46
+ }
47
+
48
+ export function resolveTargetTypeName(command) {
49
+ if (!isCommand(command)) {
50
+ return undefined
51
+ }
52
+
53
+ switch (command.commandType) {
54
+ case CommandType.InvokeStaticMethod:
55
+ case CommandType.InvokeInstanceMethod:
56
+ case CommandType.InvokeGenericStaticMethod:
57
+ case CommandType.InvokeGenericMethod:
58
+ case CommandType.GetStaticField:
59
+ case CommandType.SetStaticField:
60
+ case CommandType.GetInstanceField:
61
+ case CommandType.SetInstanceField:
62
+ case CommandType.GetStaticMethodAsDelegate:
63
+ case CommandType.GetInstanceMethodAsDelegate:
64
+ case CommandType.CreateClassInstance:
65
+ return resolveTypeNameCore(command.payload[0], 0)
66
+
67
+ case CommandType.GetType:
68
+ return resolveTypeNameCore(command, 0)
69
+
70
+ default:
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ function fallback(command) {
76
+ return `hypertube.${command.commandType}`
77
+ }
78
+
79
+ function qualify(typeName, memberName) {
80
+ if (isBlank(memberName)) {
81
+ return undefined
82
+ }
83
+ return isBlank(typeName) ? memberName : `${typeName}.${memberName}`
84
+ }
85
+
86
+ function resolveText(payloadItem, depth) {
87
+ if (depth > MAX_DEPTH) {
88
+ return undefined
89
+ }
90
+ if (typeof payloadItem === 'string') {
91
+ return payloadItem.trim() || undefined
92
+ }
93
+ if (isCommand(payloadItem) && payloadItem.commandType === CommandType.Value) {
94
+ return resolveText(payloadItem.payload[0], depth + 1)
95
+ }
96
+ return undefined
97
+ }
98
+
99
+ function resolveTypeNameCore(target, depth) {
100
+ if (depth > MAX_DEPTH || target == null) {
101
+ return undefined
102
+ }
103
+ if (typeof target === 'string') {
104
+ return target.trim() || undefined
105
+ }
106
+ if (!isCommand(target)) {
107
+ return undefined
108
+ }
109
+ return resolveTypeNameFromCommand(target, depth)
110
+ }
111
+
112
+ function resolveTypeNameFromCommand(command, depth) {
113
+ switch (command.commandType) {
114
+ case CommandType.Value:
115
+ return resolveTypeNameCore(command.payload[0], depth + 1)
116
+
117
+ case CommandType.GetType:
118
+ case CommandType.Cast:
119
+ case CommandType.CreateNull:
120
+ for (const item of command.payload) {
121
+ const name = resolveText(item, depth + 1)
122
+ if (!isBlank(name)) {
123
+ return name
124
+ }
125
+ }
126
+ return undefined
127
+
128
+ case CommandType.CreateClassInstance:
129
+ case CommandType.SetInstanceField:
130
+ case CommandType.InvokeStaticMethod:
131
+ case CommandType.InvokeInstanceMethod:
132
+ case CommandType.GetStaticField:
133
+ case CommandType.GetInstanceField:
134
+ return resolveTypeNameCore(command.payload[0], depth + 1)
135
+
136
+ default:
137
+ return undefined
138
+ }
139
+ }
@@ -0,0 +1,104 @@
1
+ import { CommandType, LogsRedactor, commandTypeName, isCommand } from 'hypertube-nodejs-sdk/abstractions'
2
+
3
+ const MAX_DEPTH = 32
4
+
5
+ const MEMBER_COMMANDS = new Set([
6
+ CommandType.InvokeStaticMethod,
7
+ CommandType.InvokeInstanceMethod,
8
+ CommandType.InvokeGenericStaticMethod,
9
+ CommandType.InvokeGenericMethod,
10
+ CommandType.GetStaticField,
11
+ CommandType.SetStaticField,
12
+ CommandType.GetInstanceField,
13
+ CommandType.SetInstanceField,
14
+ CommandType.GetStaticMethodAsDelegate,
15
+ CommandType.GetInstanceMethodAsDelegate,
16
+ ])
17
+
18
+ const TYPE_FROM_TARGET_COMMANDS = new Set([
19
+ CommandType.CreateClassInstance,
20
+ CommandType.SetInstanceField,
21
+ CommandType.InvokeStaticMethod,
22
+ CommandType.InvokeInstanceMethod,
23
+ CommandType.GetStaticField,
24
+ CommandType.GetInstanceField,
25
+ ])
26
+
27
+ export function resolveOperation(command) {
28
+ if (!isCommand(command)) {
29
+ return undefined
30
+ }
31
+
32
+ let service
33
+ let method
34
+
35
+ if (MEMBER_COMMANDS.has(command.commandType)) {
36
+ service = resolveTypeName(command.payload[0], 0)
37
+ method = resolveText(command.payload[1], 0)
38
+ } else if (command.commandType === CommandType.CreateClassInstance) {
39
+ service = resolveTypeName(command.payload[0], 0)
40
+ method = service ? 'ctor' : undefined
41
+ } else if (
42
+ command.commandType === CommandType.InvokeGlobalFunction ||
43
+ command.commandType === CommandType.GetGlobalField
44
+ ) {
45
+ method = resolveText(command.payload[0], 0)
46
+ }
47
+
48
+ if (!method) {
49
+ return undefined
50
+ }
51
+
52
+ service = service ? LogsRedactor.redactValue(service) : undefined
53
+ method = LogsRedactor.redactValue(method)
54
+ return {
55
+ name: service ? `${service}.${method}` : method,
56
+ service,
57
+ method,
58
+ commandType: commandTypeName(command.commandType),
59
+ }
60
+ }
61
+
62
+ function resolveText(item, depth) {
63
+ if (depth > MAX_DEPTH) {
64
+ return undefined
65
+ }
66
+ if (typeof item === 'string') {
67
+ return item.trim() || undefined
68
+ }
69
+ if (isCommand(item) && item.commandType === CommandType.Value) {
70
+ return resolveText(item.payload[0], depth + 1)
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ function resolveTypeName(target, depth) {
76
+ if (depth > MAX_DEPTH || target == null) {
77
+ return undefined
78
+ }
79
+ if (typeof target === 'string') {
80
+ return target.trim() || undefined
81
+ }
82
+ if (!isCommand(target)) {
83
+ return undefined
84
+ }
85
+
86
+ switch (target.commandType) {
87
+ case CommandType.Value:
88
+ return resolveTypeName(target.payload[0], depth + 1)
89
+ case CommandType.GetType:
90
+ case CommandType.Cast:
91
+ case CommandType.CreateNull:
92
+ for (const item of target.payload) {
93
+ const name = resolveText(item, depth + 1)
94
+ if (name) {
95
+ return name
96
+ }
97
+ }
98
+ return undefined
99
+ default:
100
+ return TYPE_FROM_TARGET_COMMANDS.has(target.commandType)
101
+ ? resolveTypeName(target.payload[0], depth + 1)
102
+ : undefined
103
+ }
104
+ }