diff --git a/packages/datadog-plugin-router/src/index.js b/packages/datadog-plugin-router/src/index.js index 439f2d0833..0b308723a8 100644 --- a/packages/datadog-plugin-router/src/index.js +++ b/packages/datadog-plugin-router/src/index.js @@ -29,9 +29,11 @@ class RouterPlugin extends WebPlugin { context.middleware.push(span) } - const store = storage.getStore() - this._storeStack.push(store) - this.enter(span, store) + if (span.constructor.name !== 'NoopSpan') { + const store = storage.getStore() + this._storeStack.push(store) + this.enter(span, store) + } web.patch(req) web.setRoute(req, context.route) @@ -90,6 +92,9 @@ class RouterPlugin extends WebPlugin { if (!context) return if (context.middleware.length === 0) return context.span + // if the span is no-op then use the OG request span + if (context.middleware[context.middleware.length - 1].constructor.name === 'NoopSpan') return context.span + return context.middleware[context.middleware.length - 1] } diff --git a/packages/dd-trace/src/config.js b/packages/dd-trace/src/config.js index a16df70ee0..082d7e3432 100644 --- a/packages/dd-trace/src/config.js +++ b/packages/dd-trace/src/config.js @@ -517,6 +517,8 @@ class Config { this._setValue(defaults, 'logInjection', false) this._setValue(defaults, 'lookup', undefined) this._setValue(defaults, 'inferredProxyServicesEnabled', false) + this._setValue(defaults, 'traceLevel', 'debug') + this._setValue(defaults, 'spanFilters', '') this._setValue(defaults, 'memcachedCommandEnabled', false) this._setValue(defaults, 'openAiLogsEnabled', false) this._setValue(defaults, 'openaiSpanCharLimit', 128) @@ -688,6 +690,8 @@ class Config { DD_TRACING_ENABLED, DD_VERSION, DD_TRACE_INFERRED_PROXY_SERVICES_ENABLED, + DD_TRACE_LEVEL, + DD_SPAN_FILTERS, OTEL_METRICS_EXPORTER, OTEL_PROPAGATORS, OTEL_RESOURCE_ATTRIBUTES, @@ -881,6 +885,8 @@ class Config { this._setBoolean(env, 'tracing', DD_TRACING_ENABLED) this._setString(env, 'version', DD_VERSION || tags.version) this._setBoolean(env, 'inferredProxyServicesEnabled', DD_TRACE_INFERRED_PROXY_SERVICES_ENABLED) + this._setString(env, 'traceLevel', DD_TRACE_LEVEL) + this._setString(env, 'spanFilters', DD_SPAN_FILTERS) this._setString(env, 'aws.dynamoDb.tablePrimaryKeys', DD_AWS_SDK_DYNAMODB_TABLE_PRIMARY_KEYS) } @@ -996,6 +1002,8 @@ class Config { this._setBoolean(opts, 'traceId128BitLoggingEnabled', options.traceId128BitLoggingEnabled) this._setString(opts, 'version', options.version || tags.version) this._setBoolean(opts, 'inferredProxyServicesEnabled', options.inferredProxyServicesEnabled) + this._setString(opts, 'traceLevel', options.traceLevel) + this._setString(opts, 'spanFilters', options.spanFilters) // For LLMObs, we want the environment variable to take precedence over the options. // This is reliant on environment config being set before options. diff --git a/packages/dd-trace/src/noop/span.js b/packages/dd-trace/src/noop/span.js index 1a431d090e..5b2a2a546e 100644 --- a/packages/dd-trace/src/noop/span.js +++ b/packages/dd-trace/src/noop/span.js @@ -1,14 +1,26 @@ 'use strict' const NoopSpanContext = require('./span_context') +const DatadogSpanContext = require('../opentracing/span_context') const id = require('../id') +const { performance } = require('perf_hooks') +const now = performance.now.bind(performance) +const dateNow = Date.now const { storage } = require('../../../datadog-core') // TODO: noop storage? class NoopSpan { - constructor (tracer, parent) { + constructor (tracer, parent, options) { this._store = storage.getStore() this._noopTracer = tracer - this._noopContext = this._createContext(parent) + this._noopContext = this._createContext(parent, options) + this._options = options + this._startTime = this._getTime() + } + + _getTime () { + const startTime = dateNow() + now() + + return startTime } context () { return this._noopContext } @@ -27,10 +39,16 @@ class NoopSpan { logEvent () {} finish (finishTime) {} - _createContext (parent) { + _createContext (parent, options) { const spanId = id() if (parent) { + // necessary for trace level configuration. This pattern returns the first valid span context that is not a + // NoopSpanContext, aka the next parent span in the trace that will be kept. + if (options.keepParent && parent) { + return parent instanceof DatadogSpanContext ? parent : parent.context() + } + return new NoopSpanContext({ noop: this, traceId: parent._traceId, diff --git a/packages/dd-trace/src/opentracing/tracer.js b/packages/dd-trace/src/opentracing/tracer.js index 4ae30ca93a..051999b23c 100644 --- a/packages/dd-trace/src/opentracing/tracer.js +++ b/packages/dd-trace/src/opentracing/tracer.js @@ -2,6 +2,7 @@ const os = require('os') const Span = require('./span') +const NoopSpan = require('../noop/span') const SpanProcessor = require('../span_processor') const PrioritySampler = require('../priority_sampler') const TextMapPropagator = require('./propagation/text_map') @@ -15,6 +16,7 @@ const log = require('../log') const runtimeMetrics = require('../runtime_metrics') const getExporter = require('../exporter') const SpanContext = require('./span_context') +const { SpanFilter } = require('../span_filter') const REFERENCE_CHILD_OF = 'child_of' const REFERENCE_FOLLOWS_FROM = 'follows_from' @@ -45,6 +47,7 @@ class DatadogTracer { if (config.reportHostname) { this._hostname = os.hostname() } + this._spanFilter = new SpanFilter(this) } startSpan (name, options = {}) { @@ -54,7 +57,21 @@ class DatadogTracer { // as per spec, allow the setting of service name through options const tags = { - 'service.name': options?.tags?.service ? String(options.tags.service) : this._service + 'service.name': options?.tags?.service ? String(options.tags.service) : this._service, + ...options?.tags + } + + if (options?.kind) { + tags['span.kind'] = options.kind + } + + options.tags = tags + + if (this._config.traceLevel !== 'debug' || this._config.spanFilters !== '') { + const traceLevelSpan = this._useTraceLevel(parent, options) + if (traceLevelSpan) { + return traceLevelSpan + } } // As per unified service tagging spec if a span is created with a service name different from the global @@ -81,7 +98,7 @@ class DatadogTracer { } inject (context, format, carrier) { - if (context instanceof Span) { + if (context instanceof Span || context instanceof NoopSpan) { context = context.context() } @@ -105,6 +122,18 @@ class DatadogTracer { return null } } + + _useTraceLevel (parent, options) { + // service trace level indicates service exit / entry spans only + // if (this._config.traceLevel === 'service') { + if (!this._spanFilter.shouldKeepSpan(options.tags)) { + return new NoopSpan(this, parent, { keepParent: true }) + } + // } else { + // log.warn(`Received invalid Datadog Trace Level Configuration: ${this._config.traceLevel}`) + // return null + // } + } } function getContext (spanContext) { @@ -112,6 +141,10 @@ function getContext (spanContext) { spanContext = spanContext.context() } + if (spanContext instanceof NoopSpan) { + spanContext = spanContext.context() + } + if (!(spanContext instanceof SpanContext)) { spanContext = null } diff --git a/packages/dd-trace/src/plugins/tracing.js b/packages/dd-trace/src/plugins/tracing.js index e384b8cb7a..ab712a1818 100644 --- a/packages/dd-trace/src/plugins/tracing.js +++ b/packages/dd-trace/src/plugins/tracing.js @@ -94,7 +94,7 @@ class TracingPlugin extends Plugin { } addError (error, span = this.activeSpan) { - if (span && !span._spanContext._tags.error) { + if (!span.context()._tags.error) { // Errors may be wrapped in a context. error = (error && error.error) || error span.setTag('error', error || 1) diff --git a/packages/dd-trace/src/plugins/util/web.js b/packages/dd-trace/src/plugins/util/web.js index 2d92c74ea9..8ebcfe6300 100644 --- a/packages/dd-trace/src/plugins/util/web.js +++ b/packages/dd-trace/src/plugins/util/web.js @@ -267,7 +267,7 @@ const web = { } } - const span = tracer.startSpan(name, { childOf, links: childOf?._links }) + const span = tracer.startSpan(name, { childOf, links: childOf?._links, kind: SERVER }) return span }, diff --git a/packages/dd-trace/src/span_filter.js b/packages/dd-trace/src/span_filter.js new file mode 100644 index 0000000000..58d7a64bfc --- /dev/null +++ b/packages/dd-trace/src/span_filter.js @@ -0,0 +1,188 @@ + + + +class SpanFilter { + constructor (tracer) { + let predefinedFilters = [] + if (tracer._config.traceLevel === 'service') { + predefinedFilters = traceLevelServiceFilters + } + // Load filters from environment variables + const envFilters = this.parseEnvFilters(process.env.DD_SPAN_FILTERS || '') + // Combine predefined and environment filters + this.filters = this.compileFilters([...predefinedFilters, ...envFilters]) + this.cache = new Map() + } + + /** + * Parses the SPAN_FILTERS environment variable into filter objects. + * Format: "field1[:value1],field2[:value2];field3[:value3],..." + * Example: "service=my-service,resource;tag:span.kind=server,tag:env" + */ + parseEnvFilters (envString) { + const filters = [] + const rawFilters = envString.split(';').map(f => f.trim()).filter(f => f) + + rawFilters.forEach(rawFilter => { + const criteriaStrings = rawFilter.split(',').map(c => c.trim()).filter(c => c) + const criteria = {} + + criteriaStrings.forEach(criteriaStr => { + const [fieldWithOptionalPrefix, value] = criteriaStr.split('=').map(s => s.trim()) + + // Check if field is a tag (e.g., tag:span.kind) + if (fieldWithOptionalPrefix.startsWith('tag:')) { + const tagKey = fieldWithOptionalPrefix.slice(4) + if (!criteria.tags) criteria.tags = [] + if (value) { + criteria.tags.push({ key: tagKey, value }) + } else { + criteria.tags.push({ key: tagKey }) + } + } else { + // It's a regular field (service, resource, name) + const field = fieldWithOptionalPrefix + if (!criteria[field]) criteria[field] = [] + if (value) { + criteria[field].push(value) + } else { + criteria[field].push(null) // null signifies presence check + } + } + }) + + filters.push(criteria) + }) + + return filters + } + + /** + * Compiles the filter criteria into a structured format for efficient matching. + */ + compileFilters (predefinedFilters) { + return predefinedFilters.map(filter => { + const compiled = {} + // Compile service filters + if (filter.service) { + compiled.service = new Set(filter.service) + } + // Compile resource filters + if (filter.resource) { + compiled.resource = new Set(filter.resource) + } + // Compile name filters + if (filter.name) { + compiled.name = new Set(filter.name) + } + // Compile tag filters + if (filter.tags) { + // Separate tags with values and tags without values + compiled.tagsWithValue = new Map() + compiled.tagsWithoutValue = new Set() + + filter.tags.forEach(tag => { + if (tag.value) { + if (!compiled.tagsWithValue.has(tag.key)) { + compiled.tagsWithValue.set(tag.key, new Set()) + } + compiled.tagsWithValue.get(tag.key).add(tag.value) + } else { + compiled.tagsWithoutValue.add(tag.key) + } + }) + } + return compiled + }) + } + + /** + * Generates a cache key based on relevant span fields. + */ + generateCacheKey (span) { + const { [service.name], resource, name, tags } = span + // Extract tag keys and sort them for consistency + const tagKeys = Object.keys(tags || {}).sort() + return `service:${service}|resource:${resource}|name:${name}|tags:${tagKeys.join(',')}` + } + + /** + * Determines whether to keep a span based on the compiled filters. + */ + shouldKeepSpan (span) { + const cacheKey = this.generateCacheKey(span) + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey) + } + + // Iterate through all filters; if span matches any filter, determine accordingly + let keep = false + + for (const filter of this.filters) { + let matches = true + + // Check service + if (filter.service) { + if (!span.service || !filter.service.has(span.service)) { + matches = false + } + } + + // Check resource + if (matches && filter.resource) { + if (!span.resource || !filter.resource.has(span.resource)) { + matches = false + } + } + + // Check name + if (matches && filter.name) { + if (!span.name || !filter.name.has(span.name)) { + matches = false + } + } + + // Check tags + if (matches && filter.tagsWithValue) { + for (const [key, values] of filter.tagsWithValue.entries()) { + if (!span.tags || !span.tags[key] || !values.has(span.tags[key])) { + matches = false + break + } + } + } + if (matches && filter.tagsWithoutValue) { + for (const key of filter.tagsWithoutValue) { + if (!span.tags || !span.tags[key]) { + matches = false + break + } + } + } + + if (matches) { + keep = true + break // Stop at first matching filter + } + } + + this.cache.set(cacheKey, keep) + return keep + } +} + +// Example Usage + +// Predefined filters can still be passed programmatically +const traceLevelServiceFilters = [ + // { + // service: ['my-service', 'express-app'] // Match service equal to 'my-service' or 'express-app' + // }, + { + tags: [{ key: 'span.kind' }] + } +] + +module.exports = { + SpanFilter +}