Skip to content

Commit c9ba145

Browse files
authored
feat(aiguard): set manual.keep on root span after AI Guard evaluation (#7758)
feat(aiguard): set manual.keep on root span after AI Guard evaluation Ensure AI Guard traces are always retained by setting USER_KEEP sampling priority with AI_GUARD mechanism (13) on the root span after successful evaluations, bypassing sampling rules. Co-Authored-By: Claude Opus 4.6 <[email protected]> Co-authored-by: santiago.mola <[email protected]>
1 parent dd965cf commit c9ba145

File tree

4 files changed

+86
-1
lines changed

4 files changed

+86
-1
lines changed

packages/dd-trace/src/aiguard/sdk.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const rfdc = require('../../../../vendor/dist/rfdc')({ proto: false, circles: fa
44
const log = require('../log')
55
const telemetryMetrics = require('../telemetry/metrics')
66
const tracerVersion = require('../../../../package.json').version
7+
const { keepTrace } = require('../priority_sampler')
8+
const { AI_GUARD } = require('../standalone/product')
79
const NoopAIGuard = require('./noop')
810
const executeRequest = require('./client')
911
const {
@@ -153,6 +155,12 @@ class AIGuard extends NoopAIGuard {
153155
span.meta_struct = {
154156
[AI_GUARD_META_STRUCT_KEY]: metaStruct,
155157
}
158+
const rootSpan = span.context()?._trace?.started?.[0]
159+
if (rootSpan) {
160+
// keepTrace must be called before executeRequest so the sampling decision
161+
// is propagated correctly to outgoing HTTP client calls.
162+
keepTrace(rootSpan, AI_GUARD)
163+
}
156164
let response
157165
try {
158166
const payload = {

packages/dd-trace/src/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module.exports = {
1717
SAMPLING_MECHANISM_SPAN: 8,
1818
SAMPLING_MECHANISM_REMOTE_USER: 11,
1919
SAMPLING_MECHANISM_REMOTE_DYNAMIC: 12,
20+
SAMPLING_MECHANISM_AI_GUARD: 13,
2021
SPAN_SAMPLING_MECHANISM: '_dd.span_sampling.mechanism',
2122
SPAN_SAMPLING_RULE_RATE: '_dd.span_sampling.rule_rate',
2223
SPAN_SAMPLING_MAX_PER_SECOND: '_dd.span_sampling.max_per_second',

packages/dd-trace/src/standalone/product.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const { SAMPLING_MECHANISM_APPSEC } = require('../constants')
3+
const { SAMPLING_MECHANISM_APPSEC, SAMPLING_MECHANISM_AI_GUARD } = require('../constants')
44
const RateLimiter = require('../rate_limiter')
55

66
/**
@@ -26,6 +26,7 @@ const PRODUCTS = {
2626
DSM: { id: 1 << 2 },
2727
DJM: { id: 1 << 3 },
2828
DBM: { id: 1 << 4 },
29+
AI_GUARD: { id: 1 << 5, mechanism: SAMPLING_MECHANISM_AI_GUARD },
2930
}
3031

3132
module.exports = {

packages/dd-trace/test/aiguard/index.spec.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const { assertObjectContains } = require('../../../../integration-tests/helpers'
1515
const tracerVersion = require('../../../../package.json').version
1616
const telemetryMetrics = require('../../src/telemetry/metrics')
1717
const appsecNamespace = telemetryMetrics.manager.namespace('appsec')
18+
const { USER_KEEP } = require('../../../../ext/priority')
19+
const { SAMPLING_MECHANISM_AI_GUARD, DECISION_MAKER_KEY } = require('../../src/constants')
1820

1921
describe('AIGuard SDK', () => {
2022
const config = {
@@ -477,4 +479,77 @@ describe('AIGuard SDK', () => {
477479
assertFetch(toolCall, `${endpoint}/evaluate`)
478480
})
479481
}
482+
483+
describe('manual keep on root span', () => {
484+
const assertRootSpanKept = async () => {
485+
await agent.assertSomeTraces(traces => {
486+
const rootSpan = traces[0][0]
487+
assert.strictEqual(rootSpan.metrics._sampling_priority_v1, USER_KEEP)
488+
assert.strictEqual(rootSpan.meta[DECISION_MAKER_KEY], `-${SAMPLING_MECHANISM_AI_GUARD}`)
489+
})
490+
}
491+
492+
it('sets USER_KEEP on root span after ALLOW evaluation', async () => {
493+
mockFetch({
494+
body: { data: { attributes: { action: 'ALLOW', reason: 'OK', tags: [], is_blocking_enabled: false } } },
495+
})
496+
497+
await tracer.trace('root', async () => {
498+
await aiguard.evaluate(prompt)
499+
})
500+
501+
await assertRootSpanKept()
502+
})
503+
504+
it('sets USER_KEEP on root span after DENY evaluation (non-blocking)', async () => {
505+
mockFetch({
506+
body: {
507+
data: { attributes: { action: 'DENY', reason: 'denied', tags: ['deny_tag'], is_blocking_enabled: false } },
508+
},
509+
})
510+
511+
await tracer.trace('root', async () => {
512+
await aiguard.evaluate(prompt, { block: false })
513+
})
514+
515+
await assertRootSpanKept()
516+
})
517+
518+
it('keeps trace even when auto-sampling would drop it', async () => {
519+
// Configure sampler to drop all traces (0% sample rate)
520+
tracer._tracer._prioritySampler.configure('test', { sampleRate: 0 })
521+
522+
try {
523+
mockFetch({
524+
body: { data: { attributes: { action: 'ALLOW', reason: 'OK', tags: [], is_blocking_enabled: false } } },
525+
})
526+
527+
await tracer.trace('root', async () => {
528+
await aiguard.evaluate(prompt)
529+
})
530+
531+
await assertRootSpanKept()
532+
} finally {
533+
tracer._tracer._prioritySampler.configure('test', {})
534+
}
535+
})
536+
537+
it('sets USER_KEEP on root span after ABORT evaluation (blocking)', async () => {
538+
mockFetch({
539+
body: {
540+
data: { attributes: { action: 'ABORT', reason: 'blocked', tags: ['tag'], is_blocking_enabled: true } },
541+
},
542+
})
543+
544+
await tracer.trace('root', async () => {
545+
try {
546+
await aiguard.evaluate(prompt, { block: true })
547+
} catch {
548+
// expected AIGuardAbortError
549+
}
550+
})
551+
552+
await assertRootSpanKept()
553+
})
554+
})
480555
})

0 commit comments

Comments
 (0)