Skip to content

Commit f20b306

Browse files
committed
feat(llmobs): support manual instrumentation of prompts (#7257)
* prompt support for hallucinations + basic tests * update typedocs * add more tests * updates from shared tests * fmt * fmt * change template annotation * update tests * add tagging for prompt instrumentation method (annotated) * trigger ci
1 parent a5cc4f9 commit f20b306

9 files changed

Lines changed: 522 additions & 16 deletions

File tree

docs/test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,12 @@ llmobs.annotate({
681681
outputTokens: 5,
682682
totalTokens: 15
683683
},
684-
tags: {}
684+
tags: {},
685+
prompt: {
686+
id: '123',
687+
version: '1.0.0',
688+
template: 'this is a {message}',
689+
}
685690
})
686691
llmobs.annotate(span, {
687692
inputData: 'input',

index.d.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3202,6 +3202,49 @@ declare namespace tracer {
32023202
type?: string
32033203
}
32043204

3205+
/**
3206+
* A Prompt object that represents the prompt template used for an LLM call.
3207+
* Used to power LLM Observability prompts and hallucination evaluations.
3208+
*/
3209+
interface Prompt {
3210+
/**
3211+
* Version of the prompt
3212+
*/
3213+
version?: string,
3214+
3215+
3216+
/**
3217+
* The id of the prompt set by the user. Should be unique per mlApp.
3218+
*/
3219+
id?: string,
3220+
3221+
/**
3222+
* An object of string key-value pairs that will be used to render the prompt
3223+
*/
3224+
variables?: Record<string, string>,
3225+
3226+
/**
3227+
* List of tags to add to the prompt run.
3228+
*/
3229+
tags?: Record<string, string>,
3230+
3231+
3232+
/**
3233+
* A list of variable key names that contains query information
3234+
*/
3235+
queryVariables?: string[],
3236+
3237+
/**
3238+
* A list of variable key names that contain ground truth context information.
3239+
*/
3240+
contextVariables?: string[],
3241+
3242+
/**
3243+
* A template string or chat message template list.
3244+
*/
3245+
template?: string | Message[]
3246+
}
3247+
32053248
/**
32063249
* Annotation options for LLM Observability spans.
32073250
*/
@@ -3235,7 +3278,12 @@ declare namespace tracer {
32353278
/**
32363279
* Object of JSON serializable key-value tag pairs to set or update on the LLM Observability span regarding the span's context.
32373280
*/
3238-
tags?: { [key: string]: any }
3281+
tags?: { [key: string]: any },
3282+
3283+
/**
3284+
* A Prompt object that represents the prompt used for an LLM call. Only used on `llm` spans.
3285+
*/
3286+
prompt?: Prompt,
32393287
}
32403288

32413289
interface AnnotationContextOptions {
@@ -3248,6 +3296,11 @@ declare namespace tracer {
32483296
* Set to override the span name for any spans annotated within the returned context.
32493297
*/
32503298
name?: string,
3299+
3300+
/**
3301+
* A Prompt object that represents the prompt used for an LLM call. Only used on `llm` spans.
3302+
*/
3303+
prompt?: Prompt,
32513304
}
32523305

32533306
interface RoutingContextOptions {

packages/dd-trace/src/llmobs/constants/tags.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@ module.exports = {
1717
TRACE_ID: '_ml_obs.trace_id',
1818
PROPAGATED_TRACE_ID_KEY: '_dd.p.llmobs_trace_id',
1919
ROOT_PARENT_ID: 'undefined',
20+
DEFAULT_PROMPT_NAME: 'unnamed-prompt',
21+
INTERNAL_CONTEXT_VARIABLE_KEYS: '_dd_context_variable_keys',
22+
INTERNAL_QUERY_VARIABLE_KEYS: '_dd_query_variable_keys',
2023

2124
MODEL_NAME: '_ml_obs.meta.model_name',
2225
MODEL_PROVIDER: '_ml_obs.meta.model_provider',
2326

2427
INPUT_DOCUMENTS: '_ml_obs.meta.input.documents',
2528
INPUT_MESSAGES: '_ml_obs.meta.input.messages',
2629
INPUT_VALUE: '_ml_obs.meta.input.value',
30+
INPUT_PROMPT: '_ml_obs.meta.input.prompt',
2731

2832
OUTPUT_DOCUMENTS: '_ml_obs.meta.output.documents',
2933
OUTPUT_MESSAGES: '_ml_obs.meta.output.messages',

packages/dd-trace/src/llmobs/plugins/openai/index.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,16 +422,15 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
422422
// Handle prompt tracking for reusable prompts
423423
if (inputs.prompt && response?.prompt) {
424424
const { id, version } = response.prompt // ResponsePrompt
425-
// TODO: Add proper tagger API for prompt metadata
426425
if (id && version) {
427426
const normalizedVariables = normalizePromptVariables(inputs.prompt.variables)
428427
const chatTemplate = extractChatTemplateFromInstructions(response.instructions, normalizedVariables)
429-
this._tagger._setTag(span, '_ml_obs.meta.input.prompt', {
428+
this._tagger.tagPrompt(span, {
430429
id,
431430
version,
432431
variables: normalizedVariables,
433-
chat_template: chatTemplate
434-
})
432+
template: chatTemplate
433+
}, true)
435434
const tags = { [PROMPT_TRACKING_INSTRUMENTATION_METHOD]: INSTRUMENTATION_METHOD_AUTO }
436435
if (hasMultimodalInputs(inputs.prompt.variables)) {
437436
tags[PROMPT_MULTIMODAL] = 'true'

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ class LLMObs extends NoopLLMObs {
241241
throw new Error('LLMObs span must have a span kind specified')
242242
}
243243

244-
const { inputData, outputData, metadata, metrics, tags } = options
244+
const { inputData, outputData, metadata, metrics, tags, prompt } = options
245245

246246
if (inputData || outputData) {
247247
if (spanKind === 'llm') {
@@ -264,6 +264,9 @@ class LLMObs extends NoopLLMObs {
264264
if (tags) {
265265
this._tagger.tagSpanTags(span, tags)
266266
}
267+
if (prompt) {
268+
this._tagger.tagPrompt(span, prompt)
269+
}
267270
} catch (e) {
268271
if (e.ddErrorTag) {
269272
err = e.ddErrorTag

packages/dd-trace/src/llmobs/span_processor.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const {
2727
PARENT_ID_KEY,
2828
SESSION_ID,
2929
NAME,
30+
INPUT_PROMPT,
3031
ROUTING_API_KEY,
3132
ROUTING_SITE
3233
} = require('./constants/tags')
@@ -130,11 +131,6 @@ class LLMObsSpanProcessor {
130131
inputType = 'value'
131132
}
132133

133-
// Handle prompt metadata for reusable prompts
134-
if (mlObsTags['_ml_obs.meta.input.prompt']) {
135-
input.prompt = mlObsTags['_ml_obs.meta.input.prompt']
136-
}
137-
138134
if (spanKind === 'llm' && mlObsTags[OUTPUT_MESSAGES]) {
139135
llmObsSpan.output = mlObsTags[OUTPUT_MESSAGES]
140136
outputType = 'messages'
@@ -185,6 +181,12 @@ class LLMObsSpanProcessor {
185181
if (input) meta.input = input
186182
if (output) meta.output = output
187183

184+
const prompt = mlObsTags[INPUT_PROMPT]
185+
if (prompt && spanKind === 'llm') {
186+
// by this point, we should have logged a warning if the span kind was not llm
187+
meta.input.prompt = prompt
188+
}
189+
188190
const llmObsSpanEvent = {
189191
trace_id: span.context().toTraceId(true),
190192
span_id: span.context().toSpanId(),

packages/dd-trace/src/llmobs/tagger.js

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,14 @@ const {
2929
INTEGRATION,
3030
DECORATOR,
3131
PROPAGATED_ML_APP_KEY,
32+
DEFAULT_PROMPT_NAME,
33+
INTERNAL_CONTEXT_VARIABLE_KEYS,
34+
INTERNAL_QUERY_VARIABLE_KEYS,
35+
INPUT_PROMPT,
3236
ROUTING_API_KEY,
33-
ROUTING_SITE
37+
ROUTING_SITE,
38+
PROMPT_TRACKING_INSTRUMENTATION_METHOD,
39+
INSTRUMENTATION_METHOD_ANNOTATED
3440
} = require('./constants/tags')
3541
const { storage } = require('./storage')
3642

@@ -113,6 +119,10 @@ class LLMObsTagger {
113119
const annotationContextName = annotationContext?.name
114120
if (annotationContextName) this._setTag(span, NAME, annotationContextName)
115121

122+
// apply annotation context prompt
123+
const annotationContextPrompt = annotationContext?.prompt
124+
if (annotationContextPrompt) this.tagPrompt(span, annotationContextPrompt)
125+
116126
const routing = storage.getStore()?.routingContext
117127
if (routing) {
118128
this._setTag(span, ROUTING_API_KEY, routing.apiKey)
@@ -204,6 +214,160 @@ class LLMObsTagger {
204214
}
205215
}
206216

217+
/**
218+
* Tags a prompt on an LLMObs span.
219+
* @param {import('../opentracing/span')} span
220+
* @param {string | Record<string, unknown>} prompt
221+
* @param {boolean?} strictValidation
222+
* whether to validate the prompt against the strict schema, used for auto-instrumentation
223+
*/
224+
tagPrompt (span, prompt, strictValidation = false) {
225+
const spanKind = registry.get(span)?.[SPAN_KIND]
226+
if (spanKind !== 'llm') {
227+
log.warn('Dropping prompt on non-LLM span kind, annotating prompts is only supported for LLM span kinds.')
228+
return
229+
}
230+
231+
if (!prompt || typeof prompt !== 'object') {
232+
this.#handleFailure('Prompt must be an object.', 'invalid_prompt')
233+
return
234+
}
235+
236+
const mlApp = registry.get(span)?.[ML_APP] // this should be defined at this point
237+
const {
238+
id,
239+
version,
240+
tags,
241+
variables,
242+
template,
243+
contextVariables,
244+
queryVariables,
245+
} = prompt
246+
247+
if (strictValidation) {
248+
if (id == null) {
249+
this.#handleFailure('Prompt ID is required.', 'invalid_prompt')
250+
return
251+
}
252+
253+
if (template == null) {
254+
this.#handleFailure('Prompt template is required.', 'invalid_prompt')
255+
return
256+
}
257+
}
258+
259+
const finalPromptId = id ?? `${mlApp}_${DEFAULT_PROMPT_NAME}`
260+
const finalCtxVariablesKeys = contextVariables ?? ['context']
261+
const finalQueryVariablesKeys = queryVariables ?? ['question']
262+
263+
// validate prompt id
264+
if (typeof finalPromptId !== 'string') {
265+
this.#handleFailure('Prompt ID must be a string.', 'invalid_prompt')
266+
return
267+
}
268+
269+
// validate prompt context variables keys
270+
if (Array.isArray(finalCtxVariablesKeys)) {
271+
for (const key of finalCtxVariablesKeys) {
272+
if (typeof key !== 'string') {
273+
this.#handleFailure('Prompt context variables keys must be an array of strings.', 'invalid_prompt')
274+
return
275+
}
276+
}
277+
} else if (finalCtxVariablesKeys) {
278+
this.#handleFailure('Prompt context variables keys must be an array.', 'invalid_prompt')
279+
return
280+
}
281+
282+
// validate prompt query variables keys
283+
if (Array.isArray(finalQueryVariablesKeys)) {
284+
for (const key of finalQueryVariablesKeys) {
285+
if (typeof key !== 'string') {
286+
this.#handleFailure('Prompt query variables keys must be an array of strings.', 'invalid_prompt')
287+
return
288+
}
289+
}
290+
} else if (finalQueryVariablesKeys) {
291+
this.#handleFailure('Prompt query variables keys must be an array.', 'invalid_prompt')
292+
return
293+
}
294+
295+
// validate prompt version
296+
if (version && typeof version !== 'string') {
297+
this.#handleFailure('Prompt version must be a string.', 'invalid_prompt')
298+
return
299+
}
300+
301+
// validate prompt tags
302+
if (tags && (typeof tags !== 'object' || tags instanceof Map)) {
303+
this.#handleFailure('Prompt tags must be an non-Map object.', 'invalid_prompt')
304+
return
305+
} else if (tags) {
306+
for (const [key, value] of Object.entries(tags)) {
307+
if (typeof key !== 'string' || typeof value !== 'string') {
308+
this.#handleFailure('Prompt tags must be an object of string key-value pairs.', 'invalid_prompt')
309+
return
310+
}
311+
}
312+
}
313+
314+
// validate prompt template is either string or list of messages
315+
if (template && !(typeof template === 'string' || Array.isArray(template))) {
316+
this.#handleFailure('Prompt template must be a string or an array of messages.', 'invalid_prompt')
317+
return
318+
}
319+
320+
if (Array.isArray(template)) {
321+
for (const message of template) {
322+
if (typeof message !== 'object' || !message.role || !message.content) {
323+
this.#handleFailure(
324+
'Prompt chat template must be an array of objects with role and content properties.', 'invalid_prompt'
325+
)
326+
return
327+
}
328+
}
329+
}
330+
331+
// validate variables are a string-string mapping
332+
if (variables && (typeof variables !== 'object' || variables instanceof Map)) {
333+
this.#handleFailure('Prompt variables must be an non-Map object.', 'invalid_prompt')
334+
return
335+
} else if (variables) {
336+
for (const [key, value] of Object.entries(variables)) {
337+
if (typeof key !== 'string' || typeof value !== 'string') {
338+
this.#handleFailure('Prompt variables must be an object of string key-value pairs.', 'invalid_prompt')
339+
return
340+
}
341+
}
342+
}
343+
344+
let finalTemplate, finalChatTemplate
345+
if (typeof template === 'string') {
346+
finalTemplate = template
347+
} else if (Array.isArray(template)) {
348+
finalChatTemplate = template.map(message => ({ role: message.role, content: message.content }))
349+
}
350+
351+
const validatedPrompt = {}
352+
if (finalPromptId) validatedPrompt.id = finalPromptId
353+
if (version) validatedPrompt.version = version
354+
if (variables) validatedPrompt.variables = variables
355+
if (finalTemplate) validatedPrompt.template = finalTemplate
356+
if (finalChatTemplate?.length) validatedPrompt.chat_template = finalChatTemplate
357+
if (tags) validatedPrompt.tags = tags
358+
if (finalCtxVariablesKeys) validatedPrompt[INTERNAL_CONTEXT_VARIABLE_KEYS] = finalCtxVariablesKeys
359+
if (finalQueryVariablesKeys) validatedPrompt[INTERNAL_QUERY_VARIABLE_KEYS] = finalQueryVariablesKeys
360+
361+
const currentPrompt = registry.get(span)?.[INPUT_PROMPT]
362+
if (currentPrompt) {
363+
Object.assign(currentPrompt, validatedPrompt)
364+
} else {
365+
this._setTag(span, INPUT_PROMPT, validatedPrompt)
366+
}
367+
368+
this.tagSpanTags(span, { [PROMPT_TRACKING_INSTRUMENTATION_METHOD]: INSTRUMENTATION_METHOD_ANNOTATED })
369+
}
370+
207371
changeKind (span, newKind) {
208372
this._setTag(span, SPAN_KIND, newKind)
209373
}

0 commit comments

Comments
 (0)