Skip to content

Commit c97c0a4

Browse files
committed
Add basic Open Telemetry instrumentation for model calls.
This commit wraps all LLM model calls in an Open Telemetry span that abides by the (still nascent) semantic conventions for Generative AI clients [0]. It's very similar in approach to what was done for `httr2`, and in fact the two of them complement one another nicely: r-lib/httr2#729. For example: library(otelsdk) Sys.setenv(OTEL_TRACES_EXPORTER = "stderr") chat <- ellmer::chat_databricks(model = "databricks-claude-3-7-sonnet") chat$chat("Tell me a joke in the form of an SQL query.") [0]: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ Signed-off-by: Aaron Jacobs <[email protected]>
1 parent 93235f5 commit c97c0a4

File tree

3 files changed

+57
-1
lines changed

3 files changed

+57
-1
lines changed

DESCRIPTION

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Suggests:
4141
knitr,
4242
magick,
4343
openssl,
44+
otel (>= 0.0.0.9000),
4445
paws.common,
4546
rmarkdown,
4647
shiny,
@@ -109,3 +110,5 @@ Collate:
109110
'utils-prettytime.R'
110111
'utils.R'
111112
'zzz.R'
113+
Remotes:
114+
r-lib/otel

R/chat.R

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,7 @@ Chat <- R6::R6Class(
627627
if (echo == "all") {
628628
cat_line(format(user_turn), prefix = "> ")
629629
}
630-
630+
span <- start_chat_span(private$provider)
631631
response <- chat_perform(
632632
provider = private$provider,
633633
mode = if (stream) "stream" else "value",
@@ -654,9 +654,11 @@ Chat <- R6::R6Class(
654654

655655
result <- stream_merge_chunks(private$provider, result, chunk)
656656
}
657+
end_chat_span(span, result)
657658
turn <- value_turn(private$provider, result, has_type = !is.null(type))
658659
turn <- match_tools(turn, private$tools)
659660
} else {
661+
end_chat_span(span, response)
660662
turn <- value_turn(
661663
private$provider,
662664
response,
@@ -709,6 +711,7 @@ Chat <- R6::R6Class(
709711
type = NULL,
710712
yield_as_content = FALSE
711713
) {
714+
span <- start_chat_span(private$provider)
712715
response <- chat_perform(
713716
provider = private$provider,
714717
mode = if (stream) "async-stream" else "async-value",
@@ -735,10 +738,12 @@ Chat <- R6::R6Class(
735738

736739
result <- stream_merge_chunks(private$provider, result, chunk)
737740
}
741+
end_chat_span(span, result)
738742
turn <- value_turn(private$provider, result, has_type = !is.null(type))
739743
} else {
740744
result <- await(response)
741745

746+
end_chat_span(span, result)
742747
turn <- value_turn(private$provider, result, has_type = !is.null(type))
743748
text <- turn@text
744749
if (!is.null(text)) {

R/otel.R

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Starts an Open Telemetry span that abides by the semantic conventions for
2+
# Generative AI clients.
3+
#
4+
# See: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/
5+
start_chat_span <- function(provider, tracer = NULL, scope = parent.frame()) {
6+
if (!is_installed("otel")) {
7+
return(NULL)
8+
}
9+
tracer <- tracer %||% otel::get_tracer("ellmer")
10+
name <- sprintf("chat %s", provider@model)
11+
if (!tracer$is_enabled()) {
12+
# Return a no-op span when tracing is disabled.
13+
return(tracer$start_span(name))
14+
}
15+
tracer$start_span(
16+
name,
17+
options = list(kind = "CLIENT"),
18+
# Ensure we set attributes relevant to sampling at span creation time.
19+
attributes = compact(list(
20+
"gen_ai.operation.name" = "chat",
21+
"gen_ai.system" = tolower(provider@name),
22+
"gen_ai.request.model" = provider@model
23+
)),
24+
scope = scope
25+
)
26+
}
27+
28+
end_chat_span <- function(span, result) {
29+
if (is.null(span) || !span$is_recording()) {
30+
return(invisible(span))
31+
}
32+
if (!is.null(result$model)) {
33+
span$set_attribute("gen_ai.response.model", result$model)
34+
}
35+
if (!is.null(result$id)) {
36+
span$set_attribute("gen_ai.response.id", result$id)
37+
}
38+
if (!is.null(result$usage)) {
39+
span$set_attribute("gen_ai.usage.input_tokens", result$usage$prompt_tokens)
40+
span$set_attribute(
41+
"gen_ai.usage.output_tokens",
42+
result$usage$completion_tokens
43+
)
44+
}
45+
# TODO: Consider setting gen_ai.response.finish_reasons.
46+
span$set_status("ok")
47+
span$end()
48+
}

0 commit comments

Comments
 (0)