diff --git a/Makefile b/Makefile index 42c3b08ce8..4176e6183b 100644 --- a/Makefile +++ b/Makefile @@ -507,7 +507,7 @@ itest-coverage-data: # replace the unexpected /src/cmd/obi/main.go file by the module path sed 's/^\/src\/cmd\//github.com\/open-telemetry\/opentelemetry-ebpf-instrumentation\/cmd\//' $(TEST_OUTPUT)/itest-covdata.raw.txt > $(TEST_OUTPUT)/itest-covdata.all.txt # exclude generated files from coverage data - grep -vE $(EXCLUDE_COVERAGE_FILES) $(TEST_OUTPUT)/itest-covdata.all.txt > $(TEST_OUTPUT)/itest-covdata.txt + grep -vE $(EXCLUDE_COVERAGE_FILES) $(TEST_OUTPUT)/itest-covdata.all.txt > $(TEST_OUTPUT)/itest-covdata.txt || true .PHONY: oats-prereq oats-prereq: $(GINKGO) docker-generate @@ -538,8 +538,13 @@ oats-test-mongo: oats-prereq mkdir -p internal/test/oats/mongo/$(TEST_OUTPUT)/run cd internal/test/oats/mongo && TESTCASE_TIMEOUT=5m TESTCASE_BASE_PATH=./yaml $(GINKGO) -v -r +.PHONY: oats-test-ai +oats-test-ai: oats-prereq + mkdir -p internal/test/oats/ai/$(TEST_OUTPUT)/run + cd internal/test/oats/ai && TESTCASE_TIMEOUT=5m TESTCASE_BASE_PATH=./yaml $(GINKGO) -v -r + .PHONY: oats-test -oats-test: oats-test-sql oats-test-mongo oats-test-redis oats-test-kafka oats-test-http +oats-test: oats-test-sql oats-test-mongo oats-test-redis oats-test-kafka oats-test-http oats-test-ai $(MAKE) itest-coverage-data .PHONY: oats-test-debug diff --git a/NOTICES/github.com/andybalholm/brotli/LICENSE b/NOTICES/github.com/andybalholm/brotli/LICENSE new file mode 100644 index 0000000000..33b7cdd2db --- /dev/null +++ b/NOTICES/github.com/andybalholm/brotli/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/bpf/common/large_buffers.h b/bpf/common/large_buffers.h index 6c0757fa44..1483272b4f 100644 --- a/bpf/common/large_buffers.h +++ b/bpf/common/large_buffers.h @@ -16,12 +16,15 @@ enum { // The actual size is "event size + payload". Since the payload // is guaranteed to be a power of 2, we take the next power of 2 // of the maximum payload size as a guard. - k_large_buf_max_size = 1 << 14, // 16K + k_large_buf_max_size = 1 << 15, // 32K k_large_buf_max_size_mask = k_large_buf_max_size - 1, // Maximum size for a large buffer payload. - k_large_buf_payload_max_size = 1 << 13, // 8K + k_large_buf_payload_max_size = 1 << 14, // 16K k_large_buf_payload_max_size_mask = k_large_buf_payload_max_size - 1, + + // Absolute maximum of bytes that we'll send, smaller chunks are sent one after another + k_large_buffer_read_limit = 1 << 16, // 64K }; SCRATCH_MEM_SIZED(http_large_buffers, k_large_buf_max_size); diff --git a/bpf/generictracer/k_send_receive.h b/bpf/generictracer/k_send_receive.h index cdf2317411..98339cac53 100644 --- a/bpf/generictracer/k_send_receive.h +++ b/bpf/generictracer/k_send_receive.h @@ -6,24 +6,46 @@ #include #include +#include +#include +#include + #include #include #include #include -static __always_inline void ensure_sent_event(u64 id, u64 *sock_p) { +static __always_inline u8 same_direction(pid_connection_info_t *p_conn, u8 direction) { + http_info_t *info = bpf_map_lookup_elem(&ongoing_http, p_conn); + if (info && !info->submitted) { + return ((info->type == EVENT_HTTP_REQUEST) && (direction == TCP_SEND)) || + ((info->type == EVENT_HTTP_CLIENT) && (direction == TCP_RECV)); + } + return false; +} + +static __always_inline void ensure_sent_event(u64 id, u64 *sock_p, u8 direction) { if (high_request_volume) { return; } + send_args_t *s_args = (send_args_t *)bpf_map_lookup_elem(&active_send_args, &id); if (s_args) { bpf_dbg_printk("Checking if we need to finish the request per thread id"); + + if (same_direction(&s_args->p_conn, direction)) { + return; + } + finish_possible_delayed_http_request(&s_args->p_conn); } // see if we match on another thread, but same sock * s_args = (send_args_t *)bpf_map_lookup_elem(&active_send_sock_args, sock_p); if (s_args) { bpf_dbg_printk("Checking if we need to finish the request per socket"); + if (same_direction(&s_args->p_conn, direction)) { + return; + } finish_possible_delayed_http_request(&s_args->p_conn); } } diff --git a/bpf/generictracer/k_tracer.c b/bpf/generictracer/k_tracer.c index 46dcdc5b6a..298718bb92 100644 --- a/bpf/generictracer/k_tracer.c +++ b/bpf/generictracer/k_tracer.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -698,7 +700,7 @@ static __always_inline void setup_recvmsg(u64 id, struct sock *sk, struct msghdr // sent through the same socket. This mainly happens if the server overlays virtual // threads in the runtime. u64 sock_p = (u64)sk; - ensure_sent_event(id, &sock_p); + ensure_sent_event(id, &sock_p, TCP_RECV); connect_ssl_to_sock(id, sk, TCP_RECV); recv_args_t args = { @@ -1162,10 +1164,17 @@ int obi_handle_buf_with_args(void *ctx) { } else { // large request tracking and generic TCP http_info_t *info = bpf_map_lookup_elem(&ongoing_http, &args->pid_conn); + bpf_d_printk("http info %llx, submitted %d, still reading %d", + info, + (info) ? info->submitted : 0, + (info) ? still_reading(info) : 0); + if (info && !info->submitted) { + u8 reading = still_reading(info); + u8 responding = still_responding(info); // Still reading checks if we are processing buffers of a HTTP request // that has started, but we haven't seen a response yet. - if (still_reading(info)) { + if (reading || responding) { // Packets are split into chunks if OBI injected the Traceparent // Make sure you look for split packets containing the real Traceparent. // Essentially, when a packet is extended by our sock_msg program and @@ -1175,7 +1184,7 @@ int obi_handle_buf_with_args(void *ctx) { // scan for the incoming 'Traceparent' header. If they are not reassembled // we'll see something like this: // [before the injected header],[70 bytes for 'Traceparent...'],[the rest]. - if (is_traceparent(args->small_buf)) { + if (reading && is_traceparent(args->small_buf)) { unsigned char *buf = tp_char_buf(); if (buf) { bpf_probe_read(buf, TP_SIZE, (unsigned char *)args->u_buf); @@ -1202,17 +1211,25 @@ int obi_handle_buf_with_args(void *ctx) { } } - http_send_large_buffer( - info, - (void *)args->u_buf, - args->bytes_len, - // Packet type can't be reliably determined in HTTP split packets. This should - // always be a request. - PACKET_TYPE_REQUEST, - args->direction, - k_large_buf_action_append); - } else if (still_responding(info)) { - info->end_monotime_ns = bpf_ktime_get_ns(); + u8 packet_type = PACKET_TYPE_REQUEST; + if (responding) { + packet_type = PACKET_TYPE_RESPONSE; + } + + http_send_large_buffer(info, + (void *)args->u_buf, + args->bytes_len, + packet_type, + args->direction, + k_large_buf_action_append); + + if (reading) { + info->len += args->bytes_len; + } else if (responding) { + info->end_monotime_ns = bpf_ktime_get_ns(); + bpf_d_printk("bytes len %d, new bytes %d", info->resp_len, args->bytes_len); + info->resp_len += args->bytes_len; + } } } else if (!info) { // SSL requests will see both TCP traffic and text traffic, ignore the TCP if diff --git a/bpf/generictracer/k_unix_sock.h b/bpf/generictracer/k_unix_sock.h index b2ce43185d..543ce5342d 100644 --- a/bpf/generictracer/k_unix_sock.h +++ b/bpf/generictracer/k_unix_sock.h @@ -141,7 +141,7 @@ int BPF_KPROBE(obi_kprobe_unix_stream_recvmsg, // sent through the same socket. This mainly happens if the server overlays virtual // threads in the runtime. u64 sock_p = (u64)sk; - ensure_sent_event(id, &sock_p); + ensure_sent_event(id, &sock_p, TCP_RECV); recv_args_t args = { .sock_ptr = (u64)sk, diff --git a/bpf/generictracer/protocol_http.h b/bpf/generictracer/protocol_http.h index ddad9b9f7c..87680da107 100644 --- a/bpf/generictracer/protocol_http.h +++ b/bpf/generictracer/protocol_http.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -441,22 +442,15 @@ static __always_inline void process_http_response(http_info_t *info, const unsig static __always_inline void handle_http_response(unsigned char *small_buf, pid_connection_info_t *pid_conn, http_info_t *info, - int orig_len, - u8 direction, - u8 ssl) { + int orig_len) { process_http_response(info, small_buf); cleanup_http_request_data(pid_conn, info); - if ((direction != TCP_SEND) || - high_request_volume /*|| (ssl != NO_SSL) || (orig_len < KPROBES_LARGE_RESPONSE_LEN)*/) { + if (high_request_volume) { finish_http(info, pid_conn); } else { - if (ssl) { - finish_http(info, pid_conn); - } else { - bpf_dbg_printk("Delaying finish http for large request, orig_len=%d", orig_len); - info->delayed = 1; - } + bpf_dbg_printk("Delaying finish http for large request, orig_len=%d", orig_len); + info->delayed = 1; } } @@ -483,22 +477,49 @@ static __always_inline int http_send_large_buffer(http_info_t *req, large_buf->action = action; large_buf->tp = req->tp; - large_buf->len = bytes_len; - if (large_buf->len >= http_buffer_size) { - large_buf->len = http_buffer_size; - bpf_dbg_printk("WARN: buffer is full, truncating data"); + req->has_large_buffers = true; + + u32 available_bytes = bytes_len; + // limit by the userspace requested size + if (available_bytes > http_buffer_size) { + available_bytes = http_buffer_size; } + // limit by the maximum bytes we can ever export + bpf_clamp_umax(available_bytes, k_large_buffer_read_limit); - bpf_probe_read(large_buf->buf, large_buf->len & k_large_buf_payload_max_size_mask, u_buf); + bpf_dbg_printk("sending large buffer, total size=%d, packet_type=%d, direction %d", + bytes_len, + packet_type, + direction); - u32 total_size = sizeof(tcp_large_buffer_t); - total_size += large_buf->len > sizeof(void *) ? large_buf->len : sizeof(void *); + const uint32_t niter = (available_bytes / k_large_buf_payload_max_size) + + ((available_bytes % k_large_buf_payload_max_size) > 0); - req->has_large_buffers = true; + int b = 0; + for (; b < niter; b++) { + const u32 offset = b * k_large_buf_payload_max_size; + if (offset >= k_large_buffer_read_limit) { + break; + } + u32 read_size = available_bytes; + bpf_clamp_umax(read_size, k_large_buf_payload_max_size); + bpf_probe_read(large_buf->buf, read_size, (void *)(&u_buf[offset])); + + // left here intentionally for debugging + // bpf_dbg_printk("sending large buffer, size=%d, action=%d", read_size, action); + + large_buf->len = read_size; - bpf_dbg_printk("sending large buffer, size=%d", bytes_len); + u32 total_size = sizeof(tcp_large_buffer_t); + total_size += large_buf->len > sizeof(void *) ? large_buf->len : sizeof(void *); + + bpf_clamp_umax(total_size, k_large_buf_max_size); + bpf_ringbuf_output(&events, large_buf, total_size, get_flags()); + + available_bytes -= read_size; + large_buf->action = k_large_buf_action_append; + } - bpf_ringbuf_output(&events, large_buf, total_size & k_large_buf_max_size_mask, get_flags()); return 0; } @@ -659,9 +680,9 @@ __obi_protocol_http(struct pt_regs *ctx, unsigned char *(*tp_loop_fn)(unsigned c args->packet_type, args->direction, k_large_buf_action_init); - handle_http_response( - args->small_buf, &args->pid_conn, info, args->bytes_len, args->direction, args->ssl); + handle_http_response(args->small_buf, &args->pid_conn, info, args->bytes_len); } else if (still_reading(info)) { + // print here http_send_large_buffer(info, (void *)args->u_buf, args->bytes_len, @@ -670,7 +691,9 @@ __obi_protocol_http(struct pt_regs *ctx, unsigned char *(*tp_loop_fn)(unsigned c k_large_buf_action_append); info->len += args->bytes_len; + } else if (still_responding(info)) { info->end_monotime_ns = bpf_ktime_get_ns(); + info->resp_len += args->bytes_len; } return 0; diff --git a/bpf/generictracer/ssl_defs.h b/bpf/generictracer/ssl_defs.h index 12eb1ab80b..d05cb32f0e 100644 --- a/bpf/generictracer/ssl_defs.h +++ b/bpf/generictracer/ssl_defs.h @@ -56,7 +56,7 @@ static __always_inline void cleanup_complete_ssl_server_trace(http_info_t *info, static __always_inline void finish_possible_delayed_tls_http_request(pid_connection_info_t *pid_conn, void *ssl) { http_info_t *info = bpf_map_lookup_elem(&ongoing_http, pid_conn); - if (info && (info->delayed || info->submitted)) { + if (info && info->submitted) { // we need to check for server request, the same thread // could be handling both client and server requests if (info->type == EVENT_HTTP_REQUEST) { diff --git a/docs/config-schema.json b/docs/config-schema.json index 18d80b2e53..c74745d516 100644 --- a/docs/config-schema.json +++ b/docs/config-schema.json @@ -295,7 +295,7 @@ } }, "type": "object", - "description": "Per-protocol data buffer size in bytes. Max: 8192 bytes. Default: 0 (disabled)." + "description": "Per-protocol data buffer size in bytes. Max: 64K bytes for HTTP. 8K bytes for other protocols. Default: 0 (disabled)." }, "EBPFTracer": { "properties": { @@ -681,6 +681,10 @@ "$ref": "#/$defs/GraphQLConfig", "description": "GraphQL payload extraction and parsing" }, + "openai": { + "$ref": "#/$defs/OpenAIConfig", + "description": "OpenAI payload extraction" + }, "sqlpp": { "$ref": "#/$defs/SQLPPConfig", "description": "SQL++ payload extraction and parsing (Couchbase and other SQL++ databases)" @@ -1327,6 +1331,16 @@ }, "type": "object" }, + "OpenAIConfig": { + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable OpenAI payload extraction and parsing", + "x-env-var": "OTEL_EBPF_HTTP_OPENAI_ENABLED" + } + }, + "type": "object" + }, "PayloadExtraction": { "properties": { "http": { diff --git a/go.mod b/go.mod index bba8b5ee95..249964323e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/AlessandroPomponio/go-gibberish v0.0.0-20191004143433-a2d4156f0396 + github.com/andybalholm/brotli v1.2.0 github.com/caarlos0/env/v9 v9.0.0 github.com/cilium/ebpf v0.20.0 github.com/containers/common v0.64.2 @@ -24,6 +25,7 @@ require ( github.com/hashicorp/go-version v1.8.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/invopop/jsonschema v0.13.0 + github.com/klauspost/compress v1.18.2 github.com/ory/dockertest/v3 v3.12.0 github.com/oschwald/maxminddb-golang v1.13.1 github.com/prometheus/client_golang v1.23.2 @@ -164,7 +166,6 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/providers/confmap v1.0.0 // indirect diff --git a/go.sum b/go.sum index 4980c426c7..95e3284cc9 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc= @@ -393,6 +395,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 h1:zzrxE1FKn5ryBNl9eKOeqQ58Y/Qpo3Q9QNxKHX5uzzQ= github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2/go.mod h1:hzfGeIUDq/j97IG+FhNqkowIyEcD88LrW6fyU3K3WqY= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/test/bpf/test_large_buffer_loop.c b/internal/test/bpf/test_large_buffer_loop.c new file mode 100644 index 0000000000..e8f04d0514 --- /dev/null +++ b/internal/test/bpf/test_large_buffer_loop.c @@ -0,0 +1,470 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Run me with: gcc -o test_large_buffer_loop test_large_buffer_loop.c +// ./test_large_buffer_loop + +#include +#include +#include + +// Constants from bpf/common/large_buffers.h +enum { + k_large_buf_max_size = 1 << 15, // 32K + k_large_buf_max_size_mask = k_large_buf_max_size - 1, + k_large_buf_payload_max_size = 1 << 14, // 16K + k_large_buf_payload_max_size_mask = k_large_buf_payload_max_size - 1, + k_large_buf_abs_max_size = 1 << 16, // 64K +}; + +// Large buffer action enum from bpf/common/common.h +enum large_buf_action { + k_large_buf_action_init = 0, + k_large_buf_action_append = 1, +}; + +// Simplified tcp_large_buffer_t structure +typedef struct tcp_large_buffer { + uint8_t type; + uint8_t packet_type; + enum large_buf_action action; + uint8_t direction; + uint32_t len; + uint8_t buf[k_large_buf_payload_max_size]; +} tcp_large_buffer_t; + +// Helper macro to clamp value to maximum (simulating bpf_clamp_umax) +#define bpf_clamp_umax(val, max) \ + do { \ + if ((val) > (max)) \ + (val) = (max); \ + } while (0) + +// Test result tracking +typedef struct { + int total_tests; + int passed_tests; + int failed_tests; +} test_stats_t; + +test_stats_t stats = {0, 0, 0}; + +void test_assert(int condition, const char *test_name, const char *msg) { + stats.total_tests++; + if (condition) { + stats.passed_tests++; + printf("✓ PASS: %s - %s\n", test_name, msg); + } else { + stats.failed_tests++; + printf("✗ FAIL: %s - %s\n", test_name, msg); + } +} + +// Simulating the loop from protocol_http.h +typedef struct { + int num_chunks; + int total_bytes_sent; + enum large_buf_action final_action; + int loop_iterations; +} loop_result_t; + +loop_result_t simulate_large_buffer_loop(uint32_t bytes_len, + enum large_buf_action initial_action) { + loop_result_t result = {0, 0, initial_action, 0}; + tcp_large_buffer_t large_buf; + + uint32_t available_bytes = bytes_len; + bpf_clamp_umax(available_bytes, k_large_buf_abs_max_size); + + const uint32_t niter = (available_bytes / k_large_buf_payload_max_size) + + ((available_bytes % k_large_buf_payload_max_size) > 0); + int b = 0; + for (; b < niter; b++) { + result.loop_iterations++; + + uint32_t offset = b * k_large_buf_payload_max_size; + if (offset >= k_large_buf_abs_max_size) { + break; + } + + uint32_t read_size = available_bytes; + bpf_clamp_umax(read_size, k_large_buf_payload_max_size); + + large_buf.len = read_size; + large_buf.action = (b == 0) ? initial_action : k_large_buf_action_append; + + // Simulate bpf_probe_read - just note the size + // bpf_probe_read(large_buf->buf, read_size, (void *)(&u_buf[offset])); + + uint32_t total_size = sizeof(tcp_large_buffer_t); + total_size += + large_buf.len > sizeof(void *) ? large_buf.len : sizeof(void *); + + bpf_clamp_umax(total_size, k_large_buf_max_size); + + // Simulate bpf_ringbuf_output - count the chunk + result.num_chunks++; + result.total_bytes_sent += read_size; + result.final_action = large_buf.action; + + available_bytes -= read_size; + } + + return result; +} + +// Test cases +void test_empty_buffer() { + const char *test_name = "empty_buffer"; + loop_result_t result = simulate_large_buffer_loop(0, k_large_buf_action_init); + + test_assert(result.num_chunks == 0, test_name, + "should produce 1 chunk for empty buffer"); + test_assert(result.total_bytes_sent == 0, test_name, "should send 0 bytes"); + test_assert(result.final_action == k_large_buf_action_init, test_name, + "should have init action"); + test_assert(result.loop_iterations == 0, test_name, "should iterate once"); +} + +void test_small_buffer() { + const char *test_name = "small_buffer"; + uint32_t size = 1024; + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 1, test_name, "should produce 1 chunk"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_init, test_name, + "should have init action"); + test_assert(result.loop_iterations == 1, test_name, "should iterate once"); +} + +void test_exact_chunk_size() { + const char *test_name = "exact_chunk_size"; + uint32_t size = k_large_buf_payload_max_size; // 16384 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 1, test_name, "should produce 1 chunk"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_init, test_name, + "should have init action"); + test_assert(result.loop_iterations == 1, test_name, "should iterate once"); +} + +void test_one_byte_over_chunk() { + const char *test_name = "one_byte_over_chunk"; + uint32_t size = k_large_buf_payload_max_size + 1; // 16385 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 2, test_name, "should produce 2 chunks"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_append, test_name, + "final action should be append"); + test_assert(result.loop_iterations == 2, test_name, "should iterate twice"); +} + +void test_slightly_over_chunk() { + const char *test_name = "slightly_over_chunk"; + uint32_t size = 17000; + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 2, test_name, "should produce 2 chunks"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_append, test_name, + "final action should be append"); +} + +void test_exact_two_chunks() { + const char *test_name = "exact_two_chunks"; + uint32_t size = 2 * k_large_buf_payload_max_size; // 32768 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 2, test_name, "should produce 2 chunks"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_append, test_name, + "final action should be append"); +} + +void test_three_chunks() { + const char *test_name = "three_chunks"; + uint32_t size = 3 * k_large_buf_payload_max_size; // 49152 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 3, test_name, "should produce 3 chunks"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_append, test_name, + "final action should be append"); +} + +void test_exact_abs_max() { + const char *test_name = "exact_abs_max"; + uint32_t size = k_large_buf_abs_max_size; // 65536 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 4, test_name, "should produce 4 chunks"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_append, test_name, + "final action should be append"); +} + +void test_over_abs_max() { + const char *test_name = "over_abs_max"; + uint32_t size = k_large_buf_abs_max_size + 1000; // 66536 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + // Should be clamped to abs_max + test_assert(result.num_chunks == 4, test_name, + "should produce 4 chunks (clamped)"); + test_assert(result.total_bytes_sent == k_large_buf_abs_max_size, test_name, + "should send only abs_max bytes"); + test_assert(result.final_action == k_large_buf_action_append, test_name, + "final action should be append"); +} + +void test_very_large_buffer() { + const char *test_name = "very_large_buffer"; + uint32_t size = 1024 * 1024; // 1MB + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + // Should be clamped to abs_max + test_assert(result.num_chunks == 4, test_name, + "should produce 4 chunks (clamped)"); + test_assert(result.total_bytes_sent == k_large_buf_abs_max_size, test_name, + "should send only abs_max bytes"); +} + +void test_boundary_minus_one() { + const char *test_name = "boundary_minus_one"; + uint32_t size = k_large_buf_payload_max_size - 1; // 16383 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 1, test_name, "should produce 1 chunk"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_init, test_name, + "should have init action"); +} + +void test_boundary_plus_one() { + const char *test_name = "boundary_plus_one"; + uint32_t size = k_large_buf_payload_max_size + 1; // 16385 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + test_assert(result.num_chunks == 2, test_name, "should produce 2 chunks"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); +} + +void test_with_append_action() { + const char *test_name = "with_append_action"; + uint32_t size = 2 * k_large_buf_payload_max_size; + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_append); + + test_assert(result.num_chunks == 2, test_name, "should produce 2 chunks"); + test_assert(result.total_bytes_sent == size, test_name, + "should send all bytes"); + test_assert(result.final_action == k_large_buf_action_append, test_name, + "final action should be append"); +} + +void test_chunk_distribution() { + const char *test_name = "chunk_distribution"; + + // Test that chunks are properly distributed + uint32_t size = + 3 * k_large_buf_payload_max_size + 1000; // 49152 + 1000 = 50152 + + tcp_large_buffer_t large_buf; + uint32_t available_bytes = size; + bpf_clamp_umax(available_bytes, k_large_buf_abs_max_size); + + int chunk_sizes[10] = {0}; + int chunk_count = 0; + + int b = 0; + const uint32_t niter = (available_bytes / k_large_buf_payload_max_size) + + ((available_bytes % k_large_buf_payload_max_size) > 0); + + for (; b < niter; b++) { + uint32_t offset = b * k_large_buf_payload_max_size; + if (offset >= k_large_buf_abs_max_size) { + break; + } + + uint32_t read_size = available_bytes; + bpf_clamp_umax(read_size, k_large_buf_payload_max_size); + + chunk_sizes[chunk_count++] = read_size; + + available_bytes -= read_size; + } + + test_assert(chunk_count == 4, test_name, "should have 4 chunks"); + test_assert(chunk_sizes[0] == k_large_buf_payload_max_size, test_name, + "chunk 0 should be full size"); + test_assert(chunk_sizes[1] == k_large_buf_payload_max_size, test_name, + "chunk 1 should be full size"); + test_assert(chunk_sizes[2] == k_large_buf_payload_max_size, test_name, + "chunk 2 should be full size"); + test_assert(chunk_sizes[3] == 1000, test_name, "chunk 3 should be remainder"); +} + +void test_offset_boundary() { + const char *test_name = "offset_boundary"; + + // Test that offset check works correctly + // At 4 chunks, offset would be 4 * 16384 = 65536 which equals abs_max + uint32_t size = 5 * k_large_buf_payload_max_size; // 81920 + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + // Should stop at 4 chunks due to abs_max clamping + test_assert(result.num_chunks == 4, test_name, + "should stop at 4 chunks due to offset >= abs_max"); + test_assert(result.total_bytes_sent == k_large_buf_abs_max_size, test_name, + "should send abs_max bytes"); +} + +void test_loop_termination_conditions() { + const char *test_name = "loop_termination"; + + // Test all three termination conditions: + // 1. read_size <= k_large_buf_payload_max_size (normal case) + // 2. offset >= k_large_buf_abs_max_size + // 3. b > (max / k_large_buf_payload_max_size) + + // Condition 1: read_size <= max (normal termination) + loop_result_t r1 = simulate_large_buffer_loop(1024, k_large_buf_action_init); + test_assert(r1.num_chunks == 1, test_name, + "small buffer terminates on read_size check"); + + // Condition 2: offset >= abs_max (boundary termination) + loop_result_t r2 = + simulate_large_buffer_loop(100000, k_large_buf_action_init); + test_assert(r2.total_bytes_sent <= k_large_buf_abs_max_size, test_name, + "large buffer terminates at abs_max"); +} + +void test_action_progression() { + const char *test_name = "action_progression"; + + // Verify that action changes from init to append correctly + tcp_large_buffer_t large_buf; + uint32_t size = 3 * k_large_buf_payload_max_size; + enum large_buf_action initial = k_large_buf_action_init; + + uint32_t available_bytes = size; + bpf_clamp_umax(available_bytes, k_large_buf_abs_max_size); + + enum large_buf_action actions[10]; + int action_count = 0; + + const uint32_t niter = (available_bytes / k_large_buf_payload_max_size) + + ((available_bytes % k_large_buf_payload_max_size) > 0); + + int b = 0; + for (; b < niter; b++) { + uint32_t offset = b * k_large_buf_payload_max_size; + if (offset >= k_large_buf_abs_max_size) { + break; + } + + uint32_t read_size = available_bytes; + bpf_clamp_umax(read_size, k_large_buf_payload_max_size); + + large_buf.action = (b == 0) ? initial : k_large_buf_action_append; + actions[action_count++] = large_buf.action; + + available_bytes -= k_large_buf_payload_max_size; + } + + test_assert(actions[0] == k_large_buf_action_init, test_name, + "first action should be init"); + test_assert(actions[1] == k_large_buf_action_append, test_name, + "second action should be append"); + test_assert(actions[2] == k_large_buf_action_append, test_name, + "third action should be append"); +} + +void test_max_uint32() { + const char *test_name = "max_uint32"; + uint32_t size = UINT32_MAX; + loop_result_t result = + simulate_large_buffer_loop(size, k_large_buf_action_init); + + // Should be clamped to abs_max + test_assert(result.total_bytes_sent == k_large_buf_abs_max_size, test_name, + "should clamp to abs_max"); + test_assert(result.num_chunks == 4, test_name, "should produce 4 chunks"); +} + +void print_summary() { + printf("\n"); + printf("========================================\n"); + printf("Test Summary\n"); + printf("========================================\n"); + printf("Total Tests: %d\n", stats.total_tests); + printf("Passed: %d\n", stats.passed_tests); + printf("Failed: %d\n", stats.failed_tests); + printf("========================================\n"); + + if (stats.failed_tests == 0) { + printf("✓ All tests passed!\n"); + } else { + printf("✗ Some tests failed!\n"); + } +} + +int main() { + printf("Large Buffer Loop Test Suite\n"); + printf("========================================\n"); + printf("Testing buffer chunking logic from protocol_http.h\n"); + printf("Constants:\n"); + printf(" k_large_buf_payload_max_size = %d (16K)\n", + k_large_buf_payload_max_size); + printf(" k_large_buf_abs_max_size = %d (64K)\n", k_large_buf_abs_max_size); + printf(" k_large_buf_max_size = %d (32K)\n", k_large_buf_max_size); + printf("========================================\n\n"); + + // Run all tests + test_empty_buffer(); + test_small_buffer(); + test_exact_chunk_size(); + test_one_byte_over_chunk(); + test_slightly_over_chunk(); + test_exact_two_chunks(); + test_three_chunks(); + test_exact_abs_max(); + test_over_abs_max(); + test_very_large_buffer(); + test_boundary_minus_one(); + test_boundary_plus_one(); + test_with_append_action(); + test_chunk_distribution(); + test_offset_boundary(); + test_loop_termination_conditions(); + test_action_progression(); + test_max_uint32(); + + print_summary(); + + return (stats.failed_tests == 0) ? 0 : 1; +} diff --git a/internal/test/integration/components/ai/openai/Dockerfile b/internal/test/integration/components/ai/openai/Dockerfile new file mode 100644 index 0000000000..77d6ace497 --- /dev/null +++ b/internal/test/integration/components/ai/openai/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.14@sha256:4b827abf32c14b7df9a0dc5199c2f0bc46e2c9862cd5d77eddae8a2cd8460f60 +EXPOSE 8080 +RUN pip install fastapi uvicorn requests +COPY main.py /main.py +CMD ["python", "main.py"] diff --git a/internal/test/integration/components/ai/openai/main.py b/internal/test/integration/components/ai/openai/main.py new file mode 100644 index 0000000000..9732617312 --- /dev/null +++ b/internal/test/integration/components/ai/openai/main.py @@ -0,0 +1,64 @@ +from fastapi import FastAPI, Request +import os +import uvicorn +import requests + +app = FastAPI() + +OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "http://localhost:8081") + +@app.get("/health") +async def health(): + return "ok!" + +@app.get("/messages") +async def messages(): + payload = { + "input": "How do I check if a Python object is an instance of a class?", + "instructions": "You are a coding assistant that talks like a pirate.", + "model": "gpt-5-mini", + } + resp = requests.post(f"{OPENAI_BASE_URL}/v1/responses", json=payload) + resp.raise_for_status() + return resp.json() + +@app.get("/error") +async def error_messages(): + payload = { + "input": "How do I check if a Python object is an instance of a class?", + "instructions": "You are a coding assistant that talks like a pirate.", + "model": "gpt-5-mini", + } + resp = requests.post(f"{OPENAI_BASE_URL}/v1/responses?error", json=payload) + return resp.json() + +@app.get("/chat") +async def createobject(): + payload = { + "messages": [ + {"role": "system", "content": "You are a helpful travel assistant."}, + {"role": "user", "content": "Plan a 6-day luxury trip to London for 3 people with a $4400 budget."}, + ], + "model": "gpt-4o-mini", + "temperature": 0.7, + } + resp = requests.post(f"{OPENAI_BASE_URL}/v1/chat/completions", json=payload) + resp.raise_for_status() + return resp.json() + +@app.get("/conversations") +async def conversations(): + payload = { + "items": [ + {"type":"message","role":"user","content":"Hello! I am learning Python and need some guidance."} + ], + "metadata": {"topic":"python-help","user":"nino"}, + "model": "gpt-5-mini", + } + resp = requests.post(f"{OPENAI_BASE_URL}/v1/conversations", json=payload) + resp.raise_for_status() + return resp.json() + +if __name__ == "__main__": + print(f"Server running: port={8080} process_id={os.getpid()}") + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/internal/test/integration/components/ai/openai/mock-server/Dockerfile b/internal/test/integration/components/ai/openai/mock-server/Dockerfile new file mode 100644 index 0000000000..b12717bbed --- /dev/null +++ b/internal/test/integration/components/ai/openai/mock-server/Dockerfile @@ -0,0 +1,27 @@ +# Build the mock OpenAI server binary +# Docker command must be invoked from the project root directory +FROM golang:1.25.7@sha256:cc737435e2742bd6da3b7d575623968683609a3d2e0695f9d85bee84071c08e6 AS builder + +ARG TARGETARCH + +ENV GOARCH=$TARGETARCH + +WORKDIR /src + +# Copy the go manifests and source +COPY main.go main.go + +# Build +RUN go build -o openai-mock main.go + +# Create final image from minimal + built binary +FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe + +WORKDIR / +COPY --from=builder /src/openai-mock . +USER 0:0 + +ENV OPENAI_PORT=8081 +EXPOSE 8081 + +CMD [ "/openai-mock" ] diff --git a/internal/test/integration/components/ai/openai/mock-server/main.go b/internal/test/integration/components/ai/openai/mock-server/main.go new file mode 100644 index 0000000000..01a9d7c099 --- /dev/null +++ b/internal/test/integration/components/ai/openai/mock-server/main.go @@ -0,0 +1,369 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package main implements a mock OpenAI API server for integration testing. +// It responds to POST /v1/responses and /v1/chat/completions with the same headers and gzip-compressed +// body that the real OpenAI API returns. +package main + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" +) + +const responseBody = `{ + "id": "resp_09687a288637e2be006998ad7af05481a2bb0938f77da5a9db", + "object": "response", + "created_at": 1771613562, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1771613572, + "error": null, + "frequency_penalty": 0.0, + "incomplete_details": null, + "instructions": "You are a coding assistant that talks like a pirate.", + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-5-mini-2025-08-07", + "output": [ + { + "id": "rs_09687a288637e2be006998ad7b981481a2b2e00dde500b0d5d", + "type": "reasoning", + "summary": [] + }, + { + "id": "msg_09687a288637e2be006998ad810cc881a2b84e1ea5a5decd75", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "Arrr! To check if an object be an instance of a class in Python, use isinstance." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": null, + "reasoning": { + "effort": "medium", + "summary": null + }, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 36, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 691, + "output_tokens_details": { + "reasoning_tokens": 448 + }, + "total_tokens": 727 + }, + "user": null, + "metadata": {} +}` + +const errorBody = ` +{ + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +}` + +const completionsBody = ` +{ + "id": "chatcmpl-DBTg5Ms2mJhaAhZ56Wq8QSf2djw3S", + "object": "chat.completion", + "created": 1771628061, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I now can give a great answer \nFinal Answer: \n\n**Comprehensive Travel Report for a 6-Day Luxury Trip to London, UK**\n\n**1. Best Time to Visit and Weather Conditions:**\nThe ideal time to visit London is during late spring (May to early June) and early autumn (September to October) when the weather is mild and pleasant. During these months, temperatures generally range from 15°C to 20°C (59°F to 68°F). Rain is possible at any time of the year, so packing a light raincoat or umbrella is recommended.\n\n**2. Top Attractions and Must-See Places:**\n- **The British Museum:** A world-renowned museum offering free entry, showcasing a vast collection of art and antiquities.\n- **The Tower of London:** Explore this historic castle, home to the Crown Jewels and steeped in royal history.\n- **Buckingham Palace:** Witness the Changing of the Guard and explore the beautiful surrounding gardens.\n- **The Shard:** Enjoy breathtaking views of London from the tallest building in the UK.\n- **West End Theater District:** Catch a luxurious show at one of London's famous theaters.\n- **Borough Market:** A food lover's paradise with gourmet offerings and local delicacies.\n- **Kensington Palace:** Visit the stunning royal residence and its beautiful gardens.\n\n**3. Transportation Options:**\n- **Airports:** London is served by several airports, including Heathrow (LHR), Gatwick (LGW), and London City Airport (LCY). Heathrow is the main international airport and is about 15 miles from Central London.\n- **Local Transport:** The London Underground (Tube) is the most efficient way to navigate the city. A contactless Oyster Card or contactless payment methods are recommended for easy travel. Buses, taxis, and riverboats are also excellent options for getting around.\n\n**4. Accommodation Areas and Recommendations:**\nFor a luxury experience, consider staying in the following areas:\n- **Mayfair:** Known for upscale hotels, fine dining, and luxury shopping.\n - Recommended: The Dorchester or Claridge's.\n- **Kensington:** Offers beautiful parks and close proximity to major attractions.\n - Recommended: The Milestone Hotel or The Baglioni Hotel.\n- **Covent Garden:** A vibrant area with entertainment, dining, and shopping.\n - Recommended: The Henrietta Hotel or the Covent Garden Hotel.\n\n**5. Local Customs and Cultural Considerations:**\n- **Tipping:** A 10-15% tip is customary in restaurants, though many establishments include service charges.\n- **Queuing:** The British are known for their orderly queuing; wait your turn patiently.\n- **Politeness:** Saying “please” and “thank you” is essential in British culture.\n\n**6. Safety Information and Travel Requirements:**\nLondon is generally safe for tourists, but standard precautions should be taken, such as avoiding poorly lit areas at night. As of October 2023, ensure to check for any travel advisories or entry requirements related to health, such as vaccinations or documentation.\n\n**7. Currency and Payment Methods:**\nThe currency used is the British Pound Sterling (GBP). Credit and debit cards are widely accepted, and contactless payments are very common. It's advisable to carry some cash for smaller purchases. ATMs are readily available.\n\n**8. Language Considerations:**\nThe primary language spoken is English. While most locals are fluent in English, having a few basic phrases can enhance your experience.\n\n**Final Notes:**\nWith a budget of $4400 for three travelers for six days in London, you can enjoy luxurious accommodations, gourmet dining experiences, and entrance to various attractions. Plan for a mix of fine dining and local food experiences at places like Dishoom (Indian), Sketch (high tea), and The Ivy (British cuisine). \n\nThis comprehensive travel report should serve as a valuable guide for your luxury trip to London, ensuring you experience the best that this vibrant city has to offer. Enjoy your adventure!", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 396, + "completion_tokens": 816, + "total_tokens": 1212, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_373a14eb6f" +} +` + +const conversationBody = ` +{ + "id": "conv_699c949418b08194ba11beed9ba85d9607f4edeb470fde91", + "object": "conversation", + "created_at": 1771869332, + "metadata": { + "topic": "python-help", + "user": "nino" + } +} +` + +type responsesRequest struct { + Input string `json:"input"` + Instructions string `json:"instructions"` + Model string `json:"model"` +} + +func setResponseHeaders(h http.Header) { + h.Set("X-Ratelimit-Limit-Tokens", "500000") + h.Set("X-Ratelimit-Reset-Requests", "120ms") + h.Set("X-Ratelimit-Reset-Tokens", "56ms") + h.Set("X-Ratelimit-Remaining-Tokens", "499526") + h.Set("X-Ratelimit-Remaining-Requests", "499") + h.Set("X-Ratelimit-Limit-Requests", "500") + h.Set("X-Request-Id", "req_a4bd76e7bcfc4ba4aa69aa906769538f") + h.Set("Cf-Cache-Status", "DYNAMIC") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Content-Encoding", "gzip") + h.Set("Content-Type", "application/json") + h.Set("Openai-Project", "proj_HKghDmlTiTtE4xukGeSiuu2s") + h.Set("Openai-Processing-Ms", "9377") + h.Set("Openai-Version", "2020-10-01") + h.Set("Openai-Organization", "user-kunmtqznir9mbekxyegxrwo8") + h.Set("Cf-Ray", "9d1033dc5d83a641-YYZ") + h.Set("Server", "cloudflare") + h.Set("Alt-Svc", `h3=":443"; ma=86400`) + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") + h.Set("Connection", "keep-alive") +} + +func handleResponses(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var req responsesRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + var validationErrors []string + if req.Input == "" { + validationErrors = append(validationErrors, "input cannot be empty") + } + if req.Instructions == "" { + validationErrors = append(validationErrors, "instructions cannot be empty") + } + if req.Model == "" { + validationErrors = append(validationErrors, "model cannot be empty") + } + if len(validationErrors) > 0 { + http.Error(w, "request validation failed:\n"+strings.Join(validationErrors, "\n"), http.StatusBadRequest) + return + } + + if r.URL.Query().Has("error") { + h := w.Header() + h.Set("Content-Type", "application/json") + h.Set("Openai-Version", "2020-10-01") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(errorBody)) + return + } + + h := w.Header() + setResponseHeaders(h) + w.WriteHeader(http.StatusOK) + + gz := gzip.NewWriter(w) + if _, err := gz.Write([]byte(responseBody)); err != nil { + log.Printf("error writing gzip body: %v", err) + return + } + if err := gz.Close(); err != nil { + log.Printf("error closing gzip writer: %v", err) + } +} + +type completionsRequest struct { + Messages json.RawMessage `json:"messages"` + Model string `json:"model"` + Temperature float64 `json:"temperature"` +} + +func handleCompletions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var req completionsRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + var validationErrors []string + if len(req.Messages) == 0 { + validationErrors = append(validationErrors, "messages cannot be empty") + } + if req.Temperature == 0 { + validationErrors = append(validationErrors, "temperature cannot be empty") + } + if req.Model == "" { + validationErrors = append(validationErrors, "model cannot be empty") + } + if len(validationErrors) > 0 { + http.Error(w, "request validation failed:\n"+strings.Join(validationErrors, "\n"), http.StatusBadRequest) + return + } + + if r.URL.Query().Has("error") { + h := w.Header() + h.Set("Content-Type", "application/json") + h.Set("Openai-Version", "2020-10-01") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(errorBody)) + return + } + + h := w.Header() + setResponseHeaders(h) + w.WriteHeader(http.StatusOK) + + gz := gzip.NewWriter(w) + if _, err := gz.Write([]byte(completionsBody)); err != nil { + log.Printf("error writing gzip body: %v", err) + return + } + if err := gz.Close(); err != nil { + log.Printf("error closing gzip writer: %v", err) + } +} + +type conversationRequest struct { + Items json.RawMessage `json:"items"` + Metadata json.RawMessage `json:"metadata"` +} + +func handleConversations(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var req conversationRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + var validationErrors []string + if len(req.Items) == 0 { + validationErrors = append(validationErrors, "items cannot be empty") + } + if len(req.Metadata) == 0 { + validationErrors = append(validationErrors, "metadata cannot be empty") + } + if len(validationErrors) > 0 { + http.Error(w, "request validation failed:\n"+strings.Join(validationErrors, "\n"), http.StatusBadRequest) + return + } + + if r.URL.Query().Has("error") { + h := w.Header() + h.Set("Content-Type", "application/json") + h.Set("Openai-Version", "2020-10-01") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(errorBody)) + return + } + + h := w.Header() + setResponseHeaders(h) + w.WriteHeader(http.StatusOK) + + gz := gzip.NewWriter(w) + if _, err := gz.Write([]byte(conversationBody)); err != nil { + log.Printf("error writing gzip body: %v", err) + return + } + if err := gz.Close(); err != nil { + log.Printf("error closing gzip writer: %v", err) + } +} + +func main() { + port := os.Getenv("OPENAI_PORT") + if port == "" { + port = "8081" + } + + mux := http.NewServeMux() + mux.HandleFunc("/v1/responses", handleResponses) + mux.HandleFunc("/v1/chat/completions", handleCompletions) + mux.HandleFunc("/v1/conversations", handleConversations) + + addr := ":" + port + log.Printf("mock OpenAI server listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/internal/test/integration/red_test_python_aws.go b/internal/test/integration/red_test_python_aws.go index e9c4dba222..35159deabe 100644 --- a/internal/test/integration/red_test_python_aws.go +++ b/internal/test/integration/red_test_python_aws.go @@ -60,7 +60,7 @@ func fetchAWSSpanByOP(t require.TestingT, op string) jaeger.Span { require.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, json.NewDecoder(resp.Body).Decode(&tq)) - require.GreaterOrEqual(t, len(tq.Data), 1) + require.GreaterOrEqual(t, len(tq.Data), 1, op) for _, tr := range tq.Data { spans := tr.FindByOperationName(op, "client") diff --git a/internal/test/oats/ai/configs/grafana-datasources.yaml b/internal/test/oats/ai/configs/grafana-datasources.yaml new file mode 100644 index 0000000000..c7b118ad59 --- /dev/null +++ b/internal/test/oats/ai/configs/grafana-datasources.yaml @@ -0,0 +1,41 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + url: http://prometheus:9090 + jsonData: + exemplarTraceIdDestinations: + - name: trace_id + datasourceUid: tempo + + - name: Tempo + type: tempo + uid: tempo + url: http://tempo:3200 + jsonData: + tracesToLogs: + datasourceUid: 'loki' + mappedTags: [{ key: 'service.name', value: 'job' }] + mapTagNamesEnabled: true + filterByTraceID: true + serviceMap: + datasourceUid: 'prometheus' + search: + hide: false + nodeGraph: + enabled: true + lokiSearch: + datasourceUid: 'loki' + + - name: Loki + type: loki + uid: loki + url: http://loki:3100 + jsonData: + derivedFields: + - name: 'trace_id' + matcherRegex: '"traceid":"(\w+)"' + url: '$${__value.raw}' + datasourceUid: 'tempo' diff --git a/internal/test/oats/ai/configs/instrumenter-config-traces-openai.yml b/internal/test/oats/ai/configs/instrumenter-config-traces-openai.yml new file mode 100644 index 0000000000..5a9f382ca6 --- /dev/null +++ b/internal/test/oats/ai/configs/instrumenter-config-traces-openai.yml @@ -0,0 +1,21 @@ +routes: + unmatched: path +attributes: + select: + traces: + include: + - gen_ai.input.messages + - gen_ai.system_instructions + - gen_ai.output.messages + - gen_ai.metadata +ebpf: + buffer_sizes: + http: 8192 + protocol_debug_print: true + payload_extraction: + http: + openai: + enabled: true +trace_printer: text +discovery: + exclude_otel_instrumented_services: false diff --git a/internal/test/oats/ai/configs/otelcol-config.yaml b/internal/test/oats/ai/configs/otelcol-config.yaml new file mode 100644 index 0000000000..c3aa14e032 --- /dev/null +++ b/internal/test/oats/ai/configs/otelcol-config.yaml @@ -0,0 +1,33 @@ +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + cors: + allowed_origins: + - "http://*" + - "https://*" + +exporters: + #debug: + # verbosity: detailed + # sampling_initial: 5 + # sampling_thereafter: 200 + prometheusremotewrite: + endpoint: http://prometheus:9090/api/v1/write + add_metric_suffixes: true + otlp: + endpoint: tempo:4317 + tls: + insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [otlp] + #exporters: [otlp,debug] + metrics: + receivers: [otlp] + exporters: [prometheusremotewrite] + #exporters: [prometheusremotewrite,debug] diff --git a/internal/test/oats/ai/configs/prometheus-config.yml b/internal/test/oats/ai/configs/prometheus-config.yml new file mode 100644 index 0000000000..cc4215ccc4 --- /dev/null +++ b/internal/test/oats/ai/configs/prometheus-config.yml @@ -0,0 +1,13 @@ +global: + evaluation_interval: 30s + scrape_interval: 5s +scrape_configs: + - job_name: otel + honor_labels: true + static_configs: + - targets: + - 'otelcol:9464' + - job_name: otel-collector + static_configs: + - targets: + - 'otelcol:8888' diff --git a/internal/test/oats/ai/configs/tempo-config.yaml b/internal/test/oats/ai/configs/tempo-config.yaml new file mode 100644 index 0000000000..257ac86427 --- /dev/null +++ b/internal/test/oats/ai/configs/tempo-config.yaml @@ -0,0 +1,27 @@ +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: "tempo:4317" + +storage: + trace: + backend: local + wal: + path: /tmp/tempo/wal + local: + path: /tmp/tempo/blocks + +#metrics_generator: +# storage: +# path: /tmp/tempo/generator/wal +# remote_write: +# - url: http://localhost:9090/api/v1/write +# send_exemplars: true + +#overrides: +# metrics_generator_processors: [span-metrics] diff --git a/internal/test/oats/ai/docker-compose-generic-template.yml b/internal/test/oats/ai/docker-compose-generic-template.yml new file mode 100644 index 0000000000..ff7df3e957 --- /dev/null +++ b/internal/test/oats/ai/docker-compose-generic-template.yml @@ -0,0 +1,38 @@ +version: "3.9" +services: + grafana: + image: grafana/grafana:10.4.19@sha256:a9043254ba16fb10945cc27333963dfd08eccbb43b51f1222d831cc564e3a1f4 + volumes: + - "{{ .ConfigDir }}/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/grafana-datasources.yaml" + ports: + - "{{ .GrafanaHTTPPort }}:3000" + prometheus: + image: prom/prometheus:v2.55.1@sha256:2659f4c2ebb718e7695cb9b25ffa7d6be64db013daba13e05c875451cf51b0d3 + command: + - --web.enable-remote-write-receiver + - --enable-feature=exemplar-storage + - --enable-feature=native-histograms + - --config.file=/etc/prometheus/prometheus.yml + ports: + - "{{ .PrometheusHTTPPort }}:9090" + tempo: + image: grafana/tempo:2.10.0@sha256:d7f4c72e0bad2b42b4e7263b0addaf3f5bbc105cec6b1917eba0aae3b9b70364 + volumes: + - "{{ .ConfigDir }}/tempo-config.yaml:/config.yaml" + command: + - --config.file=/config.yaml + ports: + - 4317:4317 + - "{{ .TempoHTTPPort }}:3200" +# loki: +# image: grafana/loki:2.9.0 +# ports: +# - "{{ .LokiHTTPPort }}:3100" + collector: + image: otel/opentelemetry-collector-contrib:0.144.0@sha256:213886eb6407af91b87fa47551c3632be1a6419ff3a5114ef1e6fc364628496f + volumes: + - "{{ .ConfigDir }}/otelcol-config.yaml:/config.yaml" + command: + - --config=file:/config.yaml + ports: + - 4318:4318 diff --git a/internal/test/oats/ai/docker-compose-include-base.yml b/internal/test/oats/ai/docker-compose-include-base.yml new file mode 100644 index 0000000000..7a3f4b3e89 --- /dev/null +++ b/internal/test/oats/ai/docker-compose-include-base.yml @@ -0,0 +1,3 @@ +include: +{{ range .files }}- {{ . }} +{{ end }} diff --git a/internal/test/oats/ai/docker-compose-open-ai.yml b/internal/test/oats/ai/docker-compose-open-ai.yml new file mode 100644 index 0000000000..2b26373c91 --- /dev/null +++ b/internal/test/oats/ai/docker-compose-open-ai.yml @@ -0,0 +1,50 @@ +version: "3.9" +services: + openai: + build: + context: ../../integration/components/ai/openai/mock-server + dockerfile: Dockerfile + image: openai + ports: + - "8081:8081" + testserver: + build: + context: ../../integration/components/ai/openai + dockerfile: Dockerfile + image: testserver + ports: + - "8080:8080" + environment: + OPENAI_BASE_URL: "http://openai:8081" + depends_on: + openai: + condition: service_started + # eBPF auto instrumenter + autoinstrumenter: + build: + context: ../../../.. + dockerfile: ./internal/test/integration/components/obi/Dockerfile + command: + - --config=/configs/instrumenter-config-traces-openai.yml + volumes: + - {{ .ConfigDir }}:/configs + - ./testoutput/run:/var/run/obi + - ../../../../testoutput:/coverage + cap_add: + - SYS_ADMIN + privileged: true # in some environments (not GH Pull Requests) you can set it to false and then cap_add: [ SYS_ADMIN ] + network_mode: "service:testserver" + pid: "service:testserver" + environment: + GOCOVERDIR: "/coverage" + OTEL_EBPF_OPEN_PORT: {{ .ApplicationPort }} + OTEL_EBPF_OTLP_TRACES_BATCH_TIMEOUT: "1ms" + OTEL_EBPF_SERVICE_NAMESPACE: "integration-test" + OTEL_EBPF_METRICS_INTERVAL: "10ms" + OTEL_EBPF_BPF_BATCH_TIMEOUT: "10ms" + OTEL_EBPF_LOG_LEVEL: "DEBUG" + OTEL_EBPF_BPF_DEBUG: "true" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4318" + depends_on: + testserver: + condition: service_started diff --git a/internal/test/oats/ai/go.mod b/internal/test/oats/ai/go.mod new file mode 100644 index 0000000000..4341755af7 --- /dev/null +++ b/internal/test/oats/ai/go.mod @@ -0,0 +1,55 @@ +module go.opentelemetry.io/obi/internal/test/oats/http + +go 1.25.7 + +require ( + github.com/grafana/oats v0.0.3 + github.com/onsi/ginkgo/v2 v2.19.0 + github.com/onsi/gomega v1.33.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dennwc/varint v1.0.0 // indirect + github.com/go-kit/log v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20240528025155-186aa0362fba // indirect + github.com/grafana/dashboard-linter v0.0.0-20240605160358-bb0d80454a01 // indirect + github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.54.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/prometheus v0.52.1 // indirect + go.opentelemetry.io/collector/pdata v1.9.0 // indirect + go.opentelemetry.io/otel v1.27.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect + go.opentelemetry.io/otel/metric v1.27.0 // indirect + go.opentelemetry.io/otel/sdk v1.27.0 // indirect + go.opentelemetry.io/otel/trace v1.27.0 // indirect + go.opentelemetry.io/proto/otlp v1.2.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.36.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + golang.org/x/tools v0.22.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/internal/test/oats/ai/go.sum b/internal/test/oats/ai/go.sum new file mode 100644 index 0000000000..c6bae659aa --- /dev/null +++ b/internal/test/oats/ai/go.sum @@ -0,0 +1,203 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2 h1:FDif4R1+UUR+00q6wquyX90K7A8dN+R5E8GEadoP7sU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2/go.mod h1:aiYBYui4BJ/BJCAIKs92XiPyQfTaBWqvHujDwKb6CBU= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs= +github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/aws/aws-sdk-go v1.51.25 h1:DjTT8mtmsachhV6yrXR8+yhnG6120dazr720nopRsls= +github.com/aws/aws-sdk-go v1.51.25/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps= +github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= +github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240528025155-186aa0362fba h1:ql1qNgCyOB7iAEk8JTNM+zJrgIbnyCKX/wdlyPufP5g= +github.com/google/pprof v0.0.0-20240528025155-186aa0362fba/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/dashboard-linter v0.0.0-20240605160358-bb0d80454a01 h1:bHq9v1NEqrrrfSl448klKqd2t7qz/ksa4j0xt8TZ4NI= +github.com/grafana/dashboard-linter v0.0.0-20240605160358-bb0d80454a01/go.mod h1:h5Kxj4zO2fTDgq8eXuNXxxDptf0kkqRt/dOliWtbQJg= +github.com/grafana/oats v0.0.3 h1:BK0awL9jWRpTzj/ulA7Y6CEPmduXxc1g3QcNUd7jfNk= +github.com/grafana/oats v0.0.3/go.mod h1:UyGwRG0LMYy6EEgakuiEB7NdIHkBzKhXhRPi+ek85J4= +github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= +github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= +github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8= +github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= +github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= +github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/prometheus v0.52.1 h1:BrQ29YG+mzdGh8DgHPirHbeMGNqtL+INe0rqg7ttBJ4= +github.com/prometheus/prometheus v0.52.1/go.mod h1:3z74cVsmVH0iXOR5QBjB7Pa6A0KJeEAK5A6UsmAFb1g= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/collector/pdata v1.9.0 h1:qyXe3HEVYYxerIYu0rzgo1Tx2d1Zs6iF+TCckbHLFOw= +go.opentelemetry.io/collector/pdata v1.9.0/go.mod h1:vk7LrfpyVpGZrRWcpjyy0DDZzL3SZiYMQxfap25551w= +go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= +go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= +go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= +go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= +go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= +go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= +go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= +go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= +go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= +go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= +golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= +golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= +golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= +google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= +k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/internal/test/oats/ai/oats_test.go b/internal/test/oats/ai/oats_test.go new file mode 100644 index 0000000000..64e19f8c62 --- /dev/null +++ b/internal/test/oats/ai/oats_test.go @@ -0,0 +1,46 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package oats + +import ( + "fmt" + "testing" + + "github.com/grafana/oats/yaml" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestYaml(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Yaml Suite") +} + +var _ = Describe("test case", Label("docker", "integration", "slow"), func() { + fmt.Println("First test") + cases, base := yaml.ReadTestCases() + if base != "" { + It("should have at least one test case", func() { + Expect(cases).ToNot(BeEmpty(), "expected at least one test case in %s", base) + }) + } + + configuration, _ := GinkgoConfiguration() + if configuration.ParallelTotal > 1 { + ports := yaml.NewPortAllocator(len(cases)) + for _, c := range cases { + // Ports have to be allocated before we start executing in parallel to avoid taking the same port. + // Even though it sounds unlikely, it happens quite often. + c.PortConfig = ports.AllocatePorts() + } + } + + yaml.VerboseLogging = true + + for _, c := range cases { + Describe(c.Name, Ordered, func() { + yaml.RunTestCase(c) + }) + } +}) diff --git a/internal/test/oats/ai/yaml/oats_open_ai_test.yaml b/internal/test/oats/ai/yaml/oats_open_ai_test.yaml new file mode 100644 index 0000000000..5ccb39c2e4 --- /dev/null +++ b/internal/test/oats/ai/yaml/oats_open_ai_test.yaml @@ -0,0 +1,45 @@ +docker-compose: + generator: generic + files: + - ../docker-compose-open-ai.yml +input: + - path: '/messages' + - path: '/error' + - path: '/chat' + - path: '/conversations' + +interval: 500ms +expected: + traces: + # Messages API call + - traceql: '{ .gen_ai.provider.name = "openai" && .gen_ai.request.model = "gpt-5-mini" && .gen_ai.response.id = "resp_09687a288637e2be006998ad7af05481a2bb0938f77da5a9db"}' + spans: + - name: 'response gpt-5-mini' + attributes: + gen_ai.input.messages: "How do I check if a Python object is an instance of a class?" + gen_ai.operation.name: response + gen_ai.system_instructions: "You are a coding assistant that talks like a pirate." + gen_ai.response.model: "gpt-5-mini-2025-08-07" + # Error returned by OpenAI + - traceql: '{ .error.type = "insufficient_quota" && .gen_ai.request.model = "gpt-5-mini"}' + spans: + - name: 'POST /v1/responses' + attributes: + gen_ai.input.messages: "How do I check if a Python object is an instance of a class?" + gen_ai.system_instructions: "You are a coding assistant that talks like a pirate." + gen_ai.provider.name: "openai" + # Chat completion + - traceql: '{ .gen_ai.provider.name = "openai" && .gen_ai.request.model = "gpt-4o-mini"}' + spans: + - name: 'chat.completion gpt-4o-mini' + attributes: + gen_ai.operation.name: chat.completion + gen_ai.response.model: "gpt-4o-mini-2024-07-18" + gen_ai.usage.output_tokens: 816 + # Conversations + - traceql: '{ .gen_ai.provider.name = "openai" && .gen_ai.request.model = "gpt-5-mini" && .gen_ai.response.id = "conv_699c949418b08194ba11beed9ba85d9607f4edeb470fde91"}' + spans: + - name: 'conversation gpt-5-mini' + attributes: + gen_ai.operation.name: conversation + gen_ai.conversation.id: "conv_699c949418b08194ba11beed9ba85d9607f4edeb470fde91" diff --git a/pkg/appolly/app/request/metric_attributes.go b/pkg/appolly/app/request/metric_attributes.go index a395819edd..a7fe2e6242 100644 --- a/pkg/appolly/app/request/metric_attributes.go +++ b/pkg/appolly/app/request/metric_attributes.go @@ -290,6 +290,10 @@ func DNSQuestionName(val string) attribute.KeyValue { return attribute.Key(attr.DNSQuestionName).String(val) } +func Metadata(val string) attribute.KeyValue { + return attribute.Key(attr.GenAIMetadata).String(val) +} + // These are defined here https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__TYPES.html#group__CUDART__TYPES_1gg18fa99055ee694244a270e4d5101e95bdeec295de8a74ac2a74f98ffb6c5d7c7 // in the enum cudaMemcpyKind const ( diff --git a/pkg/appolly/app/request/span.go b/pkg/appolly/app/request/span.go index be1d60129d..07e09c8865 100644 --- a/pkg/appolly/app/request/span.go +++ b/pkg/appolly/app/request/span.go @@ -88,6 +88,7 @@ const ( HTTPSubtypeAWSS3 = 3 // http + aws s3 HTTPSubtypeAWSSQS = 4 // http + aws sqs HTTPSubtypeSQLPP = 5 // http + sql++ (couchbase, etc.) + HTTPSubtypeOpenAI = 6 // http + OpenAI ) //nolint:cyclop @@ -226,6 +227,94 @@ type AWSSQS struct { MessageID string `json:"messageId"` } +type OpenAIUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` +} + +func (u *OpenAIUsage) GetInputTokens() int { + if u.InputTokens > 0 { + return u.InputTokens + } + + return u.PromptTokens +} + +func (u *OpenAIUsage) GetOutputTokens() int { + if u.OutputTokens > 0 { + return u.OutputTokens + } + + return u.CompletionTokens +} + +type OpenAIError struct { + Message string `json:"message"` + Type string `json:"type"` +} + +type OpenAI struct { + OperationName string `json:"object"` + ResponseModel string `json:"model"` + Error OpenAIError `json:"error"` + ID string `json:"id"` + FrequencyPenalty float64 `json:"frequency_penalty"` + Temperature float64 `json:"temperature"` + TopP float64 `json:"top_p"` + Usage OpenAIUsage `json:"usage"` + Output json.RawMessage `json:"output"` + Request OpenAIInput + Choices json.RawMessage `json:"choices"` + Items json.RawMessage `json:"items"` + Metadata json.RawMessage `json:"metadata"` + Data json.RawMessage `json:"data"` +} + +func (ai *OpenAI) GetOutput() string { + if len(ai.Output) > 0 { + return string(ai.Output) + } + + if len(ai.Items) > 0 { + return string(ai.Items) + } + + if len(ai.Data) > 0 { + return string(ai.Data) + } + + return string(ai.Choices) +} + +type OpenAIInput struct { + Input string `json:"input"` + Prompt string `json:"prompt"` + Model string `json:"model"` + Instructions string `json:"instructions"` + Messages json.RawMessage `json:"messages"` + Items json.RawMessage `json:"items"` + Temperature float64 `json:"temperature"` +} + +func (air *OpenAIInput) GetInput() string { + if len(air.Input) > 0 { + return air.Input + } + + if len(air.Prompt) > 0 { + return air.Prompt + } + + if len(air.Items) > 0 { + return string(air.Items) + } + + return string(air.Messages) +} + // Span contains the information being submitted by the following nodes in the graph. // It enables comfortable handling of data from Go. // REMINDER: any attribute here must be also added to the functions SpanOTELGetters @@ -268,6 +357,7 @@ type Span struct { GraphQL *GraphQL `json:"-"` Elasticsearch *Elasticsearch `json:"-"` AWS *AWS `json:"-"` + OpenAI *OpenAI `json:"-"` // OverrideTraceName is set under some conditions, like spanmetrics reaching the maximum // cardinality for trace names. @@ -610,6 +700,15 @@ func HTTPSpanStatusCode(span *Span) string { if span.Type == EventTypeHTTPClient { if span.Status < 400 { + // this is possibly not needed, because in my experiments they + // respond with 429, but just to be correct according to the OTel + // GenAI spec: https://opentelemetry.io/docs/specs/semconv/gen-ai/openai/ + if span.SubType == HTTPSubtypeOpenAI && span.OpenAI != nil { + if span.OpenAI.Error.Type != "" { + return StatusCodeError + } + } + return StatusCodeUnset } } else if span.Status < 500 { @@ -764,6 +863,20 @@ func (s *Span) TraceName() string { } } + if s.Type == EventTypeHTTPClient && s.SubType == HTTPSubtypeOpenAI && s.OpenAI != nil { + name := s.OpenAI.OperationName + if name != "" { + switch { + case s.OpenAI.Request.Model != "": + return name + " " + s.OpenAI.Request.Model + case s.OpenAI.ResponseModel != "": + return name + " " + s.OpenAI.ResponseModel + default: + return name + } + } + } + name := s.Method if s.Route != "" { name += " " + s.Route diff --git a/pkg/appolly/app/request/span_getters.go b/pkg/appolly/app/request/span_getters.go index 756c116ebb..dfbde2c952 100644 --- a/pkg/appolly/app/request/span_getters.go +++ b/pkg/appolly/app/request/span_getters.go @@ -318,6 +318,27 @@ func spanOTELGetters(name attr.Name) (attributes.Getter[*Span, attribute.KeyValu } case attr.DNSQuestionName: getter = func(span *Span) attribute.KeyValue { return DNSQuestionName(span.Path) } + case attr.GenAIInput: + getter = func(s *Span) attribute.KeyValue { + if s.Type == EventTypeHTTPClient && s.SubType == HTTPSubtypeOpenAI && s.OpenAI != nil { + return semconv.GenAIInputMessagesKey.String(s.OpenAI.Request.GetInput()) + } + return semconv.GenAIInputMessagesKey.String("") + } + case attr.GenAIOutput: + getter = func(s *Span) attribute.KeyValue { + if s.Type == EventTypeHTTPClient && s.SubType == HTTPSubtypeOpenAI && s.OpenAI != nil { + return semconv.GenAIOutputMessagesKey.String(s.OpenAI.GetOutput()) + } + return semconv.GenAIOutputMessagesKey.String("") + } + case attr.GenAIInstructions: + getter = func(s *Span) attribute.KeyValue { + if s.Type == EventTypeHTTPClient && s.SubType == HTTPSubtypeOpenAI && s.OpenAI != nil { + return semconv.GenAISystemInstructionsKey.String(s.OpenAI.Request.Instructions) + } + return semconv.GenAISystemInstructionsKey.String("") + } } // default: unlike the Prometheus getters, we don't check here for service name nor k8s metadata // because they are already attributes of the Resource instead of the attributes. diff --git a/pkg/appolly/app/request/span_test.go b/pkg/appolly/app/request/span_test.go index 870b20af83..3d42023935 100644 --- a/pkg/appolly/app/request/span_test.go +++ b/pkg/appolly/app/request/span_test.go @@ -877,3 +877,89 @@ func TestIsHTTPSpan(t *testing.T) { assert.False(t, spanGRPC.IsHTTPSpan(), "EventTypeGRPC should not be HTTP span") assert.False(t, spanOther.IsHTTPSpan(), "Other types should not be HTTP span") } + +func TestHTTPSpanStatusCode_OpenAI(t *testing.T) { + tests := []struct { + name string + span *Span + expected string + }{ + { + name: "non-OpenAI 2xx → unset", + span: &Span{ + Type: EventTypeHTTPClient, + Status: 200, + }, + expected: StatusCodeUnset, + }, + { + name: "OpenAI 2xx, no error field → unset", + span: &Span{ + Type: EventTypeHTTPClient, + SubType: HTTPSubtypeOpenAI, + Status: 200, + OpenAI: &OpenAI{ + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + }, + }, + expected: StatusCodeUnset, + }, + { + name: "OpenAI 2xx, error.type set → error", + span: &Span{ + Type: EventTypeHTTPClient, + SubType: HTTPSubtypeOpenAI, + Status: 200, + OpenAI: &OpenAI{ + Error: OpenAIError{ + Type: "insufficient_quota", + Message: "You exceeded your current quota.", + }, + }, + }, + expected: StatusCodeError, + }, + { + name: "OpenAI 2xx, OpenAI is nil → unset (nil guard)", + span: &Span{ + Type: EventTypeHTTPClient, + SubType: HTTPSubtypeOpenAI, + Status: 200, + OpenAI: nil, + }, + expected: StatusCodeUnset, + }, + { + name: "OpenAI 4xx → error (HTTP status wins regardless)", + span: &Span{ + Type: EventTypeHTTPClient, + SubType: HTTPSubtypeOpenAI, + Status: 429, + OpenAI: &OpenAI{ + Error: OpenAIError{ + Type: "insufficient_quota", + Message: "You exceeded your current quota.", + }, + }, + }, + expected: StatusCodeError, + }, + { + name: "OpenAI status 0 → error (missing status)", + span: &Span{ + Type: EventTypeHTTPClient, + SubType: HTTPSubtypeOpenAI, + Status: 0, + OpenAI: &OpenAI{}, + }, + expected: StatusCodeError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, HTTPSpanStatusCode(tt.span)) + }) + } +} diff --git a/pkg/config/ebpf_tracer.go b/pkg/config/ebpf_tracer.go index 22a327439a..2ce3b6f4e3 100644 --- a/pkg/config/ebpf_tracer.go +++ b/pkg/config/ebpf_tracer.go @@ -149,10 +149,13 @@ func (e *EBPFTracer) CudaInstrumentationEnabled() bool { } // Per-protocol data buffer size in bytes. -// Max: 8192 bytes. +// Max: +// 64K bytes for HTTP. +// 8K bytes for other protocols. +// // Default: 0 (disabled). type EBPFBufferSizes struct { - HTTP uint32 `yaml:"http" env:"OTEL_EBPF_BPF_BUFFER_SIZE_HTTP" validate:"lte=8192"` + HTTP uint32 `yaml:"http" env:"OTEL_EBPF_BPF_BUFFER_SIZE_HTTP" validate:"lte=65536"` MySQL uint32 `yaml:"mysql" env:"OTEL_EBPF_BPF_BUFFER_SIZE_MYSQL" validate:"lte=8192"` Kafka uint32 `yaml:"kafka" env:"OTEL_EBPF_BPF_BUFFER_SIZE_KAFKA" validate:"lte=8192"` Postgres uint32 `yaml:"postgres" env:"OTEL_EBPF_BPF_BUFFER_SIZE_POSTGRES" validate:"lte=8192"` diff --git a/pkg/config/payload_extraction.go b/pkg/config/payload_extraction.go index 92e4329ff2..c24bef463d 100644 --- a/pkg/config/payload_extraction.go +++ b/pkg/config/payload_extraction.go @@ -8,7 +8,7 @@ type PayloadExtraction struct { } func (p PayloadExtraction) Enabled() bool { - return p.HTTP.GraphQL.Enabled || p.HTTP.Elasticsearch.Enabled || p.HTTP.AWS.Enabled || p.HTTP.SQLPP.Enabled + return p.HTTP.GraphQL.Enabled || p.HTTP.Elasticsearch.Enabled || p.HTTP.AWS.Enabled || p.HTTP.SQLPP.Enabled || p.HTTP.OpenAI.Enabled } type HTTPConfig struct { @@ -20,6 +20,8 @@ type HTTPConfig struct { AWS AWSConfig `yaml:"aws"` // SQL++ payload extraction and parsing (Couchbase and other SQL++ databases) SQLPP SQLPPConfig `yaml:"sqlpp"` + // OpenAI payload extraction + OpenAI OpenAIConfig `yaml:"openai"` } type GraphQLConfig struct { @@ -44,3 +46,8 @@ type SQLPPConfig struct { // Example: ["/query/service", "/query"] EndpointPatterns []string `yaml:"endpoint_patterns" env:"OTEL_EBPF_HTTP_SQLPP_ENDPOINT_PATTERNS"` } + +type OpenAIConfig struct { + // Enable OpenAI payload extraction and parsing + Enabled bool `yaml:"enabled" env:"OTEL_EBPF_HTTP_OPENAI_ENABLED" validate:"boolean"` +} diff --git a/pkg/ebpf/common/http/openai.go b/pkg/ebpf/common/http/openai.go new file mode 100644 index 0000000000..f4656efba2 --- /dev/null +++ b/pkg/ebpf/common/http/openai.go @@ -0,0 +1,59 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ebpfcommon // import "go.opentelemetry.io/obi/pkg/ebpf/common/http" + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + + "go.opentelemetry.io/obi/pkg/appolly/app/request" +) + +func OpenAISpan(baseSpan *request.Span, req *http.Request, resp *http.Response) (request.Span, bool) { + // Check any of the well known response headers that OpenAI would use + isOpenAI := false + for _, header := range []string{"Openai-Version", "Openai-Organization", "Openai-Project", "Openai-Processing-Ms"} { + if val := resp.Header.Get(header); val != "" { + isOpenAI = true + break + } + } + + if !isOpenAI { + return *baseSpan, false + } + + reqB, err := io.ReadAll(req.Body) + if err != nil { + return *baseSpan, false + } + req.Body = io.NopCloser(bytes.NewBuffer(reqB)) + + respB, err := getResponseBody(resp) + if err != nil && len(respB) == 0 { + return *baseSpan, false + } + + slog.Debug("OpenAI", "request", string(reqB), "response", string(respB)) + + var parsedRequest request.OpenAIInput + if err := json.Unmarshal(reqB, &parsedRequest); err != nil { + slog.Debug("failed to parse OpenAI request", "error", err) + } + + var parsedResponse request.OpenAI + if err := json.Unmarshal(respB, &parsedResponse); err != nil { + slog.Debug("failed to parse OpenAI response", "error", err) + } + + parsedResponse.Request = parsedRequest + + baseSpan.SubType = request.HTTPSubtypeOpenAI + baseSpan.OpenAI = &parsedResponse + + return *baseSpan, true +} diff --git a/pkg/ebpf/common/http/openai_test.go b/pkg/ebpf/common/http/openai_test.go new file mode 100644 index 0000000000..2513b54bea --- /dev/null +++ b/pkg/ebpf/common/http/openai_test.go @@ -0,0 +1,288 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ebpfcommon + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/obi/pkg/appolly/app/request" +) + +// payloads match those served by internal/test/integration/components/ai/openai/mock-server/main.go + +const responsesRequestBody = `{"input":"How do I check if a Python object is an instance of a class?","instructions":"You are a coding assistant that talks like a pirate.","model":"gpt-5-mini"}` + +const responsesResponseBody = `{ + "id": "resp_09687a288637e2be006998ad7af05481a2bb0938f77da5a9db", + "object": "response", + "created_at": 1771613562, + "status": "completed", + "error": null, + "frequency_penalty": 0.0, + "instructions": "You are a coding assistant that talks like a pirate.", + "model": "gpt-5-mini-2025-08-07", + "output": [ + { + "id": "msg_09687a288637e2be006998ad810cc881a2b84e1ea5a5decd75", + "type": "message", + "status": "completed", + "content": [{"type":"output_text","text":"Arrr! To check if an object be an instance of a class in Python, use isinstance."}], + "role": "assistant" + } + ], + "temperature": 1.0, + "top_p": 1.0, + "usage": { + "input_tokens": 36, + "output_tokens": 691, + "total_tokens": 727 + } +}` + +const completionsRequestBody = `{"messages":[{"role":"system","content":"You are a helpful travel assistant."},{"role":"user","content":"Plan a 6-day luxury trip to London for 3 people with a $4400 budget."}],"model":"gpt-4o-mini","temperature":1.0}` + +const completionsResponseBody = `{ + "id": "chatcmpl-DBTg5Ms2mJhaAhZ56Wq8QSf2djw3S", + "object": "chat.completion", + "created": 1771628061, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": {"role":"assistant","content":"I now can give a great answer"}, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 396, + "completion_tokens": 816, + "total_tokens": 1212 + }, + "service_tier": "default" +}` + +const quotaErrorResponseBody = `{ + "error": { + "message": "You exceeded your current quota, please check your plan and billing details.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +}` + +func gzipBody(t *testing.T, body string) io.ReadCloser { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + _, err := gz.Write([]byte(body)) + require.NoError(t, err) + require.NoError(t, gz.Close()) + return io.NopCloser(&buf) +} + +//nolint:unparam +func makeRequest(t *testing.T, method, url, body string) *http.Request { + t.Helper() + req, err := http.NewRequest(method, url, strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + return req +} + +func openAIHeaders() http.Header { + h := http.Header{} + h.Set("Content-Type", "application/json") + h.Set("Content-Encoding", "gzip") + h.Set("Openai-Version", "2020-10-01") + h.Set("Openai-Organization", "user-kunmtqznir9mbekxyegxrwo8") + h.Set("Openai-Project", "proj_HKghDmlTiTtE4xukGeSiuu2s") + h.Set("Openai-Processing-Ms", "9377") + return h +} + +func makeGzipResponse(t *testing.T, statusCode int, headers http.Header, body string) *http.Response { + t.Helper() + return &http.Response{ + StatusCode: statusCode, + Header: headers, + Body: gzipBody(t, body), + } +} + +func makePlainResponse(statusCode int, headers http.Header, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Header: headers, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestOpenAISpan_Responses(t *testing.T) { + req := makeRequest(t, http.MethodPost, "http://api.openai.com/v1/responses", responsesRequestBody) + resp := makeGzipResponse(t, http.StatusOK, openAIHeaders(), responsesResponseBody) + + base := &request.Span{} + span, ok := OpenAISpan(base, req, resp) + + require.True(t, ok) + require.NotNil(t, span.OpenAI) + assert.Equal(t, request.HTTPSubtypeOpenAI, span.SubType) + + ai := span.OpenAI + assert.Equal(t, "resp_09687a288637e2be006998ad7af05481a2bb0938f77da5a9db", ai.ID) + assert.Equal(t, "response", ai.OperationName) + assert.Equal(t, "gpt-5-mini-2025-08-07", ai.ResponseModel) + assert.Equal(t, 36, ai.Usage.GetInputTokens()) + assert.Equal(t, 691, ai.Usage.GetOutputTokens()) + assert.InEpsilon(t, 1.0, 0.01, ai.Temperature) + assert.InEpsilon(t, 1.0, 0.01, ai.TopP) + assert.NotEmpty(t, ai.Output) + + // request fields + assert.Equal(t, "How do I check if a Python object is an instance of a class?", ai.Request.Input) + assert.Equal(t, "You are a coding assistant that talks like a pirate.", ai.Request.Instructions) + assert.Equal(t, "gpt-5-mini", ai.Request.Model) +} + +func TestOpenAISpan_ChatCompletions(t *testing.T) { + req := makeRequest(t, http.MethodPost, "http://api.openai.com/v1/chat/completions", completionsRequestBody) + resp := makeGzipResponse(t, http.StatusOK, openAIHeaders(), completionsResponseBody) + + base := &request.Span{} + span, ok := OpenAISpan(base, req, resp) + + require.True(t, ok) + require.NotNil(t, span.OpenAI) + + ai := span.OpenAI + assert.Equal(t, "chatcmpl-DBTg5Ms2mJhaAhZ56Wq8QSf2djw3S", ai.ID) + assert.Equal(t, "chat.completion", ai.OperationName) + assert.Equal(t, "gpt-4o-mini-2024-07-18", ai.ResponseModel) + assert.Equal(t, 396, ai.Usage.GetInputTokens()) + assert.Equal(t, 816, ai.Usage.GetOutputTokens()) + assert.NotEmpty(t, ai.Choices) + + // request fields + assert.Equal(t, "gpt-4o-mini", ai.Request.Model) + assert.NotEmpty(t, ai.Request.Messages) +} + +func TestOpenAISpan_ErrorResponse(t *testing.T) { + h := http.Header{} + h.Set("Content-Type", "application/json") + h.Set("Openai-Version", "2020-10-01") + // error responses are plain JSON (no gzip) + resp := makePlainResponse(http.StatusTooManyRequests, h, quotaErrorResponseBody) + + req := makeRequest(t, http.MethodPost, "http://api.openai.com/v1/responses", responsesRequestBody) + base := &request.Span{} + span, ok := OpenAISpan(base, req, resp) + + require.True(t, ok) + require.NotNil(t, span.OpenAI) + + ai := span.OpenAI + assert.Equal(t, "insufficient_quota", ai.Error.Type) + assert.NotEmpty(t, ai.Error.Message) +} + +func TestOpenAISpan_NotOpenAI(t *testing.T) { + req := makeRequest(t, http.MethodPost, "http://example.com/api", `{"query":"hello"}`) + resp := makePlainResponse(http.StatusOK, http.Header{ + "Content-Type": []string{"application/json"}, + }, `{"result":"ok"}`) + + base := &request.Span{} + _, ok := OpenAISpan(base, req, resp) + + assert.False(t, ok, "should not be detected as OpenAI when no OpenAI headers are present") +} + +func TestOpenAISpan_OnlyOneOpenAIHeaderSuffices(t *testing.T) { + for _, header := range []string{"Openai-Version", "Openai-Organization", "Openai-Project", "Openai-Processing-Ms"} { + t.Run(header, func(t *testing.T) { + h := http.Header{} + h.Set("Content-Type", "application/json") + h.Set(header, "some-value") + resp := makePlainResponse(http.StatusOK, h, responsesResponseBody) + req := makeRequest(t, http.MethodPost, "http://api.openai.com/v1/responses", responsesRequestBody) + + base := &request.Span{} + _, ok := OpenAISpan(base, req, resp) + assert.True(t, ok, "header %q alone should be enough to identify an OpenAI response", header) + }) + } +} + +func TestOpenAISpan_MalformedResponseBody(t *testing.T) { + h := openAIHeaders() + h.Del("Content-Encoding") // plain, but invalid JSON + resp := makePlainResponse(http.StatusOK, h, `not-json`) + + req := makeRequest(t, http.MethodPost, "http://api.openai.com/v1/responses", responsesRequestBody) + base := &request.Span{} + span, ok := OpenAISpan(base, req, resp) + + // Still detected as OpenAI (headers present), span returned even if JSON is junk + assert.True(t, ok) + assert.NotNil(t, span.OpenAI) + // but no meaningful fields are populated + assert.Empty(t, span.OpenAI.ID) +} + +func TestOpenAISpan_UsageTokenHelpers(t *testing.T) { + // /v1/responses uses input_tokens / output_tokens + u := request.OpenAIUsage{InputTokens: 10, OutputTokens: 20, TotalTokens: 30} + assert.Equal(t, 10, u.GetInputTokens()) + assert.Equal(t, 20, u.GetOutputTokens()) + + // /v1/chat/completions uses prompt_tokens / completion_tokens + u2 := request.OpenAIUsage{PromptTokens: 5, CompletionTokens: 15} + assert.Equal(t, 5, u2.GetInputTokens()) + assert.Equal(t, 15, u2.GetOutputTokens()) +} + +func TestOpenAISpan_GetOutput(t *testing.T) { + // output field populated (responses API) + ai := &request.OpenAI{Output: []byte(`[{"type":"message"}]`)} + assert.JSONEq(t, `[{"type":"message"}]`, ai.GetOutput()) + + // items fallback + ai2 := &request.OpenAI{Items: []byte(`[{"item":1}]`)} + assert.JSONEq(t, `[{"item":1}]`, ai2.GetOutput()) + + // data fallback + ai3 := &request.OpenAI{Data: []byte(`[{"id":"emb-1"}]`)} + assert.JSONEq(t, `[{"id":"emb-1"}]`, ai3.GetOutput()) + + // choices fallback (completions API) + ai4 := &request.OpenAI{Choices: []byte(`[{"index":0}]`)} + assert.JSONEq(t, `[{"index":0}]`, ai4.GetOutput()) +} + +func TestOpenAIInput_GetInput(t *testing.T) { + // direct input string + inp := &request.OpenAIInput{Input: "hello"} + assert.Equal(t, "hello", inp.GetInput()) + + // prompt fallback (completions v1) + inp2 := &request.OpenAIInput{Prompt: "pirate prompt"} + assert.Equal(t, "pirate prompt", inp2.GetInput()) + + // messages fallback + inp3 := &request.OpenAIInput{Messages: []byte(`[{"role":"user"}]`)} + assert.JSONEq(t, `[{"role":"user"}]`, inp3.GetInput()) + + // items fallback + inp4 := &request.OpenAIInput{Items: []byte(`[{"item":1}]`)} + assert.JSONEq(t, `[{"item":1}]`, inp4.GetInput()) +} diff --git a/pkg/ebpf/common/http/responses.go b/pkg/ebpf/common/http/responses.go new file mode 100644 index 0000000000..d0c121f506 --- /dev/null +++ b/pkg/ebpf/common/http/responses.go @@ -0,0 +1,68 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ebpfcommon // import "go.opentelemetry.io/obi/pkg/ebpf/common/http" + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "fmt" + "io" + "net/http" + + "github.com/andybalholm/brotli" + "github.com/klauspost/compress/zstd" +) + +// getResponseBody tries to read the body as plain text and then +// if it's encoded in compressed format, it tries to decompress +func getResponseBody(resp *http.Response) ([]byte, error) { + respB, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(respB)) + + // http.ReadResponse does NOT auto-decompress Content-Encoding + // (only http.Transport does, and only for gzip). Decompress manually. + body := respB + if enc := resp.Header.Get("Content-Encoding"); enc != "" && len(respB) > 0 { + dec, err := decompressBody(enc, respB) + if err != nil { + return nil, fmt.Errorf("decompress error (enc=%s, truncated body?): %w", enc, err) + } + body = dec + } + + return body, nil +} + +// decompressBody decompresses b according to the Content-Encoding value. +// Mirrors what http.Transport does for gzip, extended to cover zstd, deflate and brotli. +func decompressBody(encoding string, b []byte) ([]byte, error) { + switch encoding { + case "gzip": + gr, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer gr.Close() + return io.ReadAll(gr) + case "zstd": + zr, err := zstd.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer zr.Close() + return io.ReadAll(zr) + case "deflate": + fr := flate.NewReader(bytes.NewReader(b)) + defer fr.Close() + return io.ReadAll(fr) + case "br": + return io.ReadAll(brotli.NewReader(bytes.NewReader(b))) + default: + return b, nil + } +} diff --git a/pkg/ebpf/common/http/responses_test.go b/pkg/ebpf/common/http/responses_test.go new file mode 100644 index 0000000000..a8fcd1dc1f --- /dev/null +++ b/pkg/ebpf/common/http/responses_test.go @@ -0,0 +1,227 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ebpfcommon + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "io" + "net/http" + "strings" + "testing" + + "github.com/andybalholm/brotli" + "github.com/klauspost/compress/zstd" +) + +const testPayload = `{"hello":"world"}` + +// helpers to produce compressed bytes + +func gzipEncode(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, _ = w.Write(data) + _ = w.Close() + return buf.Bytes() +} + +func zstdEncode(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w, err := zstd.NewWriter(&buf) + if err != nil { + t.Fatalf("zstd.NewWriter: %v", err) + } + _, _ = w.Write(data) + _ = w.Close() + return buf.Bytes() +} + +func deflateEncode(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w, err := flate.NewWriter(&buf, flate.DefaultCompression) + if err != nil { + t.Fatalf("flate.NewWriter: %v", err) + } + _, _ = w.Write(data) + _ = w.Close() + return buf.Bytes() +} + +func brotliEncode(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w := brotli.NewWriter(&buf) + _, _ = w.Write(data) + _ = w.Close() + return buf.Bytes() +} + +func makeResponse(t *testing.T, body []byte, encoding string) *http.Response { + t.Helper() + resp := &http.Response{ + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(body)), + } + if encoding != "" { + resp.Header.Set("Content-Encoding", encoding) + } + return resp +} + +func TestDecompressBody(t *testing.T) { + tests := []struct { + name string + encoding string + encode func(*testing.T, []byte) []byte + }{ + {"gzip", "gzip", gzipEncode}, + {"zstd", "zstd", zstdEncode}, + {"deflate", "deflate", deflateEncode}, + {"brotli", "br", brotliEncode}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + compressed := tc.encode(t, []byte(testPayload)) + got, err := decompressBody(tc.encoding, compressed) + if err != nil { + t.Fatalf("decompressBody(%q): unexpected error: %v", tc.encoding, err) + } + if string(got) != testPayload { + t.Errorf("decompressBody(%q): got %q, want %q", tc.encoding, got, testPayload) + } + }) + } + + t.Run("unknown encoding passthrough", func(t *testing.T) { + got, err := decompressBody("identity", []byte(testPayload)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != testPayload { + t.Errorf("got %q, want %q", got, testPayload) + } + }) + + t.Run("gzip corrupted data returns error", func(t *testing.T) { + _, err := decompressBody("gzip", []byte("notgzip")) + if err == nil { + t.Fatal("expected error for corrupted gzip data, got nil") + } + }) + + t.Run("zstd corrupted data returns error", func(t *testing.T) { + _, err := decompressBody("zstd", []byte("notzstd")) + if err == nil { + t.Fatal("expected error for corrupted zstd data, got nil") + } + }) + + t.Run("brotli corrupted data returns error", func(t *testing.T) { + _, err := decompressBody("br", []byte("notbrotli")) + if err == nil { + t.Fatal("expected error for corrupted brotli data, got nil") + } + }) +} + +func TestGetResponseBody(t *testing.T) { + t.Run("no encoding returns plain body", func(t *testing.T) { + resp := makeResponse(t, []byte(testPayload), "") + got, err := getResponseBody(resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != testPayload { + t.Errorf("got %q, want %q", got, testPayload) + } + }) + + t.Run("gzip encoding decompressed", func(t *testing.T) { + resp := makeResponse(t, gzipEncode(t, []byte(testPayload)), "gzip") + got, err := getResponseBody(resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != testPayload { + t.Errorf("got %q, want %q", got, testPayload) + } + }) + + t.Run("zstd encoding decompressed", func(t *testing.T) { + resp := makeResponse(t, zstdEncode(t, []byte(testPayload)), "zstd") + got, err := getResponseBody(resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != testPayload { + t.Errorf("got %q, want %q", got, testPayload) + } + }) + + t.Run("deflate encoding decompressed", func(t *testing.T) { + resp := makeResponse(t, deflateEncode(t, []byte(testPayload)), "deflate") + got, err := getResponseBody(resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != testPayload { + t.Errorf("got %q, want %q", got, testPayload) + } + }) + + t.Run("brotli encoding decompressed", func(t *testing.T) { + resp := makeResponse(t, brotliEncode(t, []byte(testPayload)), "br") + got, err := getResponseBody(resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != testPayload { + t.Errorf("got %q, want %q", got, testPayload) + } + }) + + t.Run("empty body with encoding header returns empty", func(t *testing.T) { + resp := makeResponse(t, []byte{}, "gzip") + got, err := getResponseBody(resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty body, got %q", got) + } + }) + + t.Run("body is restored and readable after call", func(t *testing.T) { + resp := makeResponse(t, []byte(testPayload), "") + _, err := getResponseBody(resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // resp.Body should have been replaced with a fresh reader + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("re-reading body: %v", err) + } + if string(b) != testPayload { + t.Errorf("restored body: got %q, want %q", b, testPayload) + } + }) + + t.Run("corrupted gzip body returns error", func(t *testing.T) { + resp := makeResponse(t, []byte("notgzip"), "gzip") + _, err := getResponseBody(resp) + if err == nil { + t.Fatal("expected error for corrupted gzip body, got nil") + } + if !strings.Contains(err.Error(), "decompress error") { + t.Errorf("error message should mention decompress error, got: %v", err) + } + }) +} diff --git a/pkg/ebpf/common/http_transform.go b/pkg/ebpf/common/http_transform.go index 18c5749b54..478bdee1d1 100644 --- a/pkg/ebpf/common/http_transform.go +++ b/pkg/ebpf/common/http_transform.go @@ -86,16 +86,36 @@ func httpRequestResponseToSpan(parseCtx *EBPFParseContext, event *BPFHTTPInfo, r scheme = "http" } } + + // Make sure the content length is non-zero + reqContentLen := req.ContentLength + if reqContentLen <= 0 { + reqContentLen = int64(event.Len) + } + + // The response len can be -1 if we use chunked + // responses + respContentLen := resp.ContentLength + if respContentLen <= 0 { + respContentLen = int64(event.RespLen) + } + + reqType := request.EventType(event.Type) + headerHost := req.Host + if headerHost == "" && reqType == request.EventTypeHTTPClient { + headerHost, _ = httpHostFromBuf(event.Buf[:]) + } + httpSpan := request.Span{ - Type: request.EventType(event.Type), + Type: reqType, Method: req.Method, Path: removeQuery(req.URL.String()), Peer: peer, PeerPort: int(event.ConnInfo.S_port), Host: host, HostPort: int(event.ConnInfo.D_port), - ContentLength: req.ContentLength, - ResponseLength: resp.ContentLength, + ContentLength: reqContentLen, + ResponseLength: respContentLen, RequestStart: int64(event.ReqMonotimeNs), Start: int64(event.StartMonotimeNs), End: int64(event.EndMonotimeNs), @@ -109,7 +129,7 @@ func httpRequestResponseToSpan(parseCtx *EBPFParseContext, event *BPFHTTPInfo, r UserPID: app.PID(event.Pid.UserPid), Namespace: event.Pid.Ns, }, - Statement: scheme + request.SchemeHostSeparator + req.Host, + Statement: scheme + request.SchemeHostSeparator + headerHost, } if isClientEvent(event.Type) && parseCtx != nil && parseCtx.payloadExtraction.HTTP.AWS.Enabled { @@ -145,6 +165,13 @@ func httpRequestResponseToSpan(parseCtx *EBPFParseContext, event *BPFHTTPInfo, r } } + if isClientEvent(event.Type) && parseCtx != nil && parseCtx.payloadExtraction.HTTP.OpenAI.Enabled { + span, ok := ebpfhttp.OpenAISpan(&httpSpan, req, resp) + if ok { + return span + } + } + return httpSpan } @@ -169,12 +196,15 @@ func HTTPInfoEventToSpan(parseCtx *EBPFParseContext, event *BPFHTTPInfo) (reques isClient = isClientEvent(event.Type) ) + slog.Debug("Event", "traceID", event.Tp.TraceId, "conn", event.ConnInfo, "buf", event.Buf[:]) + if event.HasLargeBuffers == 1 { b, ok := extractTCPLargeBuffer(parseCtx, event.Tp.TraceId, packetTypeRequest, directionByPacketType(packetTypeRequest, isClient), event.ConnInfo) if ok { requestBuffer = b } else { slog.Debug("missing large buffer for HTTP request", "traceID", event.Tp.TraceId, "conn", event.ConnInfo, "packetType", packetTypeRequest) + requestBuffer = event.Buf[:] } b, ok = extractTCPLargeBuffer(parseCtx, event.Tp.TraceId, packetTypeResponse, directionByPacketType(packetTypeResponse, isClient), event.ConnInfo) diff --git a/pkg/ebpf/common/tcp_large_buffer.go b/pkg/ebpf/common/tcp_large_buffer.go index 83a9a59074..9d2b816782 100644 --- a/pkg/ebpf/common/tcp_large_buffer.go +++ b/pkg/ebpf/common/tcp_large_buffer.go @@ -43,7 +43,7 @@ func appendTCPLargeBuffer(parseCtx *EBPFParseContext, record *ringbuf.Record) (r } if parseCtx.protocolDebug { - fmt.Printf(">>> LargeBufferAppend: (packet=%d direction=%d action=%d)\n%s\n", event.PacketType, event.Direction, event.Action, string(record.RawSample[hdrSize:hdrSize+event.Len])) + fmt.Printf(">>> LargeBufferAppend: (packet=%d direction=%d action=%d size=%d)\n%s\n", event.PacketType, event.Direction, event.Action, event.Len, string(record.RawSample[hdrSize:hdrSize+event.Len])) } switch event.Action { @@ -77,7 +77,7 @@ func extractTCPLargeBuffer(parseCtx *EBPFParseContext, traceID [16]uint8, packet //nolint:gocritic if lb, ok := parseCtx.largeBuffers.Get(key); ok { if parseCtx.protocolDebug { - fmt.Printf("<<< LargeBufferExtract: (packet=%d direction=%d)\n%s\n", key.packetType, key.direction, string(lb.buf)) + fmt.Printf("<<< LargeBufferExtract: (packet=%d direction=%d len=%d)\n%s\n", key.packetType, key.direction, len(lb.buf), string(lb.buf)) } parseCtx.largeBuffers.Remove(key) return lb.buf, true diff --git a/pkg/export/attributes/attr_defs.go b/pkg/export/attributes/attr_defs.go index e782436d21..915e1518d0 100644 --- a/pkg/export/attributes/attr_defs.go +++ b/pkg/export/attributes/attr_defs.go @@ -325,7 +325,11 @@ func getDefinitions( }, Traces.Section: { Attributes: map[attr.Name]Default{ - attr.DBQueryText: false, + attr.DBQueryText: false, + attr.GenAIInput: false, + attr.GenAIOutput: false, + attr.GenAIInstructions: false, + attr.GenAIMetadata: false, }, }, GPUCudaKernelLaunchCalls.Section: { diff --git a/pkg/export/attributes/names/attrs.go b/pkg/export/attributes/names/attrs.go index 064136d292..95c005f86c 100644 --- a/pkg/export/attributes/names/attrs.go +++ b/pkg/export/attributes/names/attrs.go @@ -198,6 +198,12 @@ const ( // Cloud CloudRegion = Name(semconv.CloudRegionKey) + + // GenAI + GenAIInput = Name(semconv.GenAIInputMessagesKey) + GenAIInstructions = Name(semconv.GenAISystemInstructionsKey) + GenAIOutput = Name(semconv.GenAIOutputMessagesKey) + GenAIMetadata = Name("gen_ai.metadata") ) // OBI specific GPU events diff --git a/pkg/export/otel/traces_test.go b/pkg/export/otel/traces_test.go index 71dcb850dc..6f6cb30eb1 100644 --- a/pkg/export/otel/traces_test.go +++ b/pkg/export/otel/traces_test.go @@ -680,6 +680,291 @@ func TestGenerateTracesAttributes(t *testing.T) { ensureTraceStrAttr(t, attrs, "source.upstream", "OBI") ensureTraceStrAttr(t, attrs, "otel.scope.name", "my-reporter") }) + + makeOpenAISpan := func(ai *request.OpenAI) request.Span { + return request.Span{ + Type: request.EventTypeHTTPClient, + SubType: request.HTTPSubtypeOpenAI, + Method: "POST", + Path: "https://api.openai.com/v1/responses", + Status: 200, + OpenAI: ai, + } + } + + t.Run("OpenAI span - core attributes, no optional", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Temperature: 1.0, + TopP: 1.0, + Usage: request.OpenAIUsage{InputTokens: 36, OutputTokens: 691, TotalTokens: 727}, + Request: request.OpenAIInput{ + Input: "How do I check if a Python object is an instance of a class?", + Instructions: "You are a coding assistant that talks like a pirate.", + Model: "gpt-5-mini", + }, + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + require.Equal(t, 1, traces.ResourceSpans().Len()) + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIProviderNameKey, "openai") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIOperationNameKey, "response") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIResponseIDKey, "resp_abc123") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIRequestModelKey, "gpt-5-mini") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIResponseModelKey, "gpt-5-mini-2025-08-07") + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIInputMessagesKey) + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIOutputMessagesKey) + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAISystemInstructionsKey) + }) + + t.Run("OpenAI span - optional GenAIInput enabled", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Temperature: 1.0, + Request: request.OpenAIInput{ + Input: "How do I check if a Python object is an instance of a class?", + Instructions: "You are a coding assistant that talks like a pirate.", + Model: "gpt-5-mini", + }, + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{attr.GenAIInput: {}}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIInputMessagesKey, "How do I check if a Python object is an instance of a class?") + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIOutputMessagesKey) + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAISystemInstructionsKey) + }) + + t.Run("OpenAI span - optional GenAIOutput enabled", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Output: []byte(`[{"type":"message","role":"assistant","content":"Arrr!"}]`), + Request: request.OpenAIInput{Model: "gpt-5-mini"}, + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{attr.GenAIOutput: {}}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIOutputMessagesKey, `[{"type":"message","role":"assistant","content":"Arrr!"}]`) + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIInputMessagesKey) + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAISystemInstructionsKey) + }) + + t.Run("OpenAI span - optional GenAIInstructions enabled", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Request: request.OpenAIInput{ + Model: "gpt-5-mini", + Instructions: "You are a coding assistant that talks like a pirate.", + }, + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{attr.GenAIInstructions: {}}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceStrAttr(t, spanAttrs, semconv.GenAISystemInstructionsKey, "You are a coding assistant that talks like a pirate.") + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIInputMessagesKey) + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIOutputMessagesKey) + }) + + t.Run("OpenAI span - instructions not emitted when empty even if attr enabled", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Request: request.OpenAIInput{Model: "gpt-5-mini"}, // no Instructions + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{attr.GenAIInstructions: {}}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAISystemInstructionsKey) + }) + + t.Run("OpenAI span - all optional attributes enabled", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Temperature: 1.0, + TopP: 1.0, + Usage: request.OpenAIUsage{InputTokens: 36, OutputTokens: 691}, + Output: []byte(`[{"type":"message","role":"assistant","content":"Arrr!"}]`), + Request: request.OpenAIInput{ + Input: "How do I check if a Python object is an instance of a class?", + Instructions: "You are a coding assistant that talks like a pirate.", + Model: "gpt-5-mini", + }, + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{ + attr.GenAIInput: {}, + attr.GenAIOutput: {}, + attr.GenAIInstructions: {}, + }) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIInputMessagesKey, "How do I check if a Python object is an instance of a class?") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIOutputMessagesKey, `[{"type":"message","role":"assistant","content":"Arrr!"}]`) + ensureTraceStrAttr(t, spanAttrs, semconv.GenAISystemInstructionsKey, "You are a coding assistant that talks like a pirate.") + }) + + t.Run("OpenAI span - optional GenAIMetadata enabled", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Request: request.OpenAIInput{Model: "gpt-5-mini"}, + Metadata: []byte(`{"session_id":"sess_42","user":"alice"}`), + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{attr.GenAIMetadata: {}}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceStrAttr(t, spanAttrs, attribute.Key(attr.GenAIMetadata), `{"session_id":"sess_42","user":"alice"}`) + }) + + t.Run("OpenAI span - GenAIMetadata not emitted when metadata is empty", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Request: request.OpenAIInput{Model: "gpt-5-mini"}, + Metadata: nil, // no metadata + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{attr.GenAIMetadata: {}}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceAttrNotExists(t, spanAttrs, attribute.Key(attr.GenAIMetadata)) + }) + + t.Run("OpenAI span - GenAIMetadata not emitted without attr selector", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "resp_abc123", + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Request: request.OpenAIInput{Model: "gpt-5-mini"}, + Metadata: []byte(`{"session_id":"sess_42"}`), + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{}) // GenAIMetadata NOT in optional set + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceAttrNotExists(t, spanAttrs, attribute.Key(attr.GenAIMetadata)) + }) + + t.Run("OpenAI span - error response", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + OperationName: "response", + Request: request.OpenAIInput{Model: "gpt-5-mini"}, + Error: request.OpenAIError{ + Type: "insufficient_quota", + Message: "You exceeded your current quota, please check your plan and billing details.", + }, + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceStrAttr(t, spanAttrs, semconv.ErrorTypeKey, "insufficient_quota") + ensureTraceStrAttr(t, spanAttrs, attribute.Key("error.message"), "You exceeded your current quota, please check your plan and billing details.") + }) + + t.Run("OpenAI span - chat completions (prompt/completion token fields)", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + ID: "chatcmpl-DBTg5Ms2mJhaAhZ56Wq8QSf2djw3S", + OperationName: "chat.completion", + ResponseModel: "gpt-4o-mini-2024-07-18", + Temperature: 1.0, + Usage: request.OpenAIUsage{PromptTokens: 396, CompletionTokens: 816}, + Choices: []byte(`[{"index":0,"message":{"role":"assistant","content":"I now can give a great answer"},"finish_reason":"stop"}]`), + Request: request.OpenAIInput{ + Model: "gpt-4o-mini", + Temperature: 1.0, + Messages: []byte(`[{"role":"system","content":"You are a helpful travel assistant."},{"role":"user","content":"Plan a 6-day luxury trip to London."}]`), + }, + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{ + attr.GenAIInput: {}, + attr.GenAIOutput: {}, + }) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIProviderNameKey, "openai") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIOperationNameKey, "chat.completion") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIResponseIDKey, "chatcmpl-DBTg5Ms2mJhaAhZ56Wq8QSf2djw3S") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIRequestModelKey, "gpt-4o-mini") + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIResponseModelKey, "gpt-4o-mini-2024-07-18") + // input/output messages come through the optional attrs + ensureTraceStrAttr(t, spanAttrs, semconv.GenAIOutputMessagesKey, `[{"index":0,"message":{"role":"assistant","content":"I now can give a great answer"},"finish_reason":"stop"}]`) + }) + + t.Run("OpenAI span - temperature from request when response temperature is zero", func(t *testing.T) { + span := makeOpenAISpan(&request.OpenAI{ + OperationName: "response", + ResponseModel: "gpt-5-mini-2025-08-07", + Temperature: 0, // not set in response + Request: request.OpenAIInput{ + Model: "gpt-5-mini", + Temperature: 0.7, + }, + }) + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{}) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + v, ok := spanAttrs.Get(string(semconv.GenAIRequestTemperatureKey)) + require.True(t, ok, "gen_ai.request.temperature should be present") + assert.InDelta(t, 0.7, v.Double(), 0.001) + }) + + t.Run("OpenAI span - nil OpenAI field keeps span with no GenAI attrs", func(t *testing.T) { + span := request.Span{ + Type: request.EventTypeHTTPClient, + SubType: request.HTTPSubtypeOpenAI, + Method: "POST", + Status: 200, + OpenAI: nil, // explicitly nil + } + + tAttrs := tracesgen.TraceAttributesSelector(&span, map[attr.Name]struct{}{ + attr.GenAIInput: {}, + attr.GenAIOutput: {}, + attr.GenAIInstructions: {}, + }) + traces := tracesgen.GenerateTracesWithAttributes(cache, &span.Service, []attribute.KeyValue{}, hostID, groupFromSpanAndAttributes(&span, tAttrs), reporterName) + + spanAttrs := traces.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIProviderNameKey) + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIOperationNameKey) + ensureTraceAttrNotExists(t, spanAttrs, semconv.GenAIInputMessagesKey) + }) } func TestTraceSampling(t *testing.T) { diff --git a/pkg/export/otel/tracesgen/tracesgen.go b/pkg/export/otel/tracesgen/tracesgen.go index ca2cb8bb3c..79cd3f2c57 100644 --- a/pkg/export/otel/tracesgen/tracesgen.go +++ b/pkg/export/otel/tracesgen/tracesgen.go @@ -419,6 +419,53 @@ func TraceAttributesSelector(span *request.Span, optionalAttrs map[attr.Name]str attrs = append(attrs, request.AWSExtendedRequestID(sqs.Meta.ExtendedRequestID)) attrs = append(attrs, request.AWSSQSQueueURL(sqs.QueueURL)) } + + if span.SubType == request.HTTPSubtypeOpenAI && span.OpenAI != nil { + ai := span.OpenAI + attrs = append(attrs, semconv.GenAIProviderNameOpenAI) + attrs = append(attrs, semconv.GenAIOperationNameKey.String(ai.OperationName)) + attrs = append(attrs, semconv.GenAIResponseID(ai.ID)) + if ai.OperationName == "conversation" || ai.OperationName == "chatkit.session" || ai.OperationName == "chatkit.thread" { + attrs = append(attrs, semconv.GenAIConversationID(ai.ID)) + } + attrs = append(attrs, semconv.GenAIRequestModel(ai.Request.Model)) + attrs = append(attrs, semconv.GenAIResponseModel(ai.ResponseModel)) + if ai.FrequencyPenalty > 0.0 { + attrs = append(attrs, semconv.GenAIRequestFrequencyPenalty(ai.FrequencyPenalty)) + } + if ai.Temperature > 0.0 { + attrs = append(attrs, semconv.GenAIRequestTemperature(ai.Temperature)) + } else if ai.Request.Temperature != 0 { + attrs = append(attrs, semconv.GenAIRequestTemperature(ai.Request.Temperature)) + } + if ai.TopP > 0.0 { + attrs = append(attrs, semconv.GenAIRequestTopP(ai.TopP)) + } + attrs = append(attrs, semconv.GenAIUsageInputTokens(ai.Usage.GetInputTokens())) + attrs = append(attrs, semconv.GenAIUsageOutputTokens(ai.Usage.GetOutputTokens())) + if _, ok := optionalAttrs[attr.GenAIInput]; ok { + attrs = append(attrs, semconv.GenAIInputMessagesKey.String(ai.Request.GetInput())) + } + if _, ok := optionalAttrs[attr.GenAIOutput]; ok { + attrs = append(attrs, semconv.GenAIOutputMessagesKey.String(ai.GetOutput())) + } + if _, ok := optionalAttrs[attr.GenAIInstructions]; ok { + if ai.Request.Instructions != "" { + attrs = append(attrs, semconv.GenAISystemInstructionsKey.String(ai.Request.Instructions)) + } + } + if _, ok := optionalAttrs[attr.GenAIMetadata]; ok { + if len(ai.Metadata) > 0 { + attrs = append(attrs, request.Metadata(string(ai.Metadata))) + } + } + // add error info + if ai.Error.Type != "" { + attrs = append(attrs, semconv.ErrorTypeKey.String(ai.Error.Type)) + attrs = append(attrs, semconv.ErrorMessage(ai.Error.Message)) + } + } + case request.EventTypeGRPCClient: attrs = []attribute.KeyValue{ semconv.RPCMethod(span.Path), diff --git a/pkg/internal/ebpf/generictracer/generictracer.go b/pkg/internal/ebpf/generictracer/generictracer.go index 1c9c5f4548..bd3bae76db 100644 --- a/pkg/internal/ebpf/generictracer/generictracer.go +++ b/pkg/internal/ebpf/generictracer/generictracer.go @@ -543,12 +543,12 @@ func (p *Tracer) lookForTimeouts(ctx context.Context, parseCtx *ebpfcommon.EBPFP // but it hasn't been posted yet, likely missed by the logic that looks at finishing requests // where we track the full response. If we haven't updated the EndMonotimeNs in more than some // short interval, we are likely not going to finish this request from eBPF, so let's do it here. - if v.EndMonotimeNs != 0 && v.Submitted == 0 && t.After(kernelTime(v.EndMonotimeNs).Add(2*time.Second)) { + if v.EndMonotimeNs != 0 && v.Submitted == 0 && t.After(kernelTime(v.EndMonotimeNs).Add(10*time.Second)) { // Must use unsafe here, the two bpfHttpInfoTs are the same but generated from different // ebpf2go outputs s, ignore, err := ebpfcommon.HTTPInfoEventToSpan(parseCtx, (*ebpfcommon.BPFHTTPInfo)(unsafe.Pointer(&v))) if !ignore && err == nil { - eventsChan.Send(p.pidsFilter.Filter([]request.Span{s})) + eventsChan.SendCtx(ctx, p.pidsFilter.Filter([]request.Span{s})) } if err := p.bpfObjects.OngoingHttp.Delete(k); err != nil { p.log.Debug("Error deleting ongoing request", "error", err) @@ -565,7 +565,7 @@ func (p *Tracer) lookForTimeouts(ctx context.Context, parseCtx *ebpfcommon.EBPFP } s.End = s.Start + p.cfg.EBPF.HTTPRequestTimeout.Nanoseconds() - eventsChan.Send(p.pidsFilter.Filter([]request.Span{s})) + eventsChan.SendCtx(ctx, p.pidsFilter.Filter([]request.Span{s})) } if err := p.bpfObjects.OngoingHttp.Delete(k); err != nil { p.log.Debug("Error deleting ongoing request", "error", err) diff --git a/pkg/obi/config.go b/pkg/obi/config.go index e99c693de9..fb3ba29c65 100644 --- a/pkg/obi/config.go +++ b/pkg/obi/config.go @@ -152,6 +152,9 @@ var DefaultConfig = Config{ "/query/service", }, }, + OpenAI: config.OpenAIConfig{ + Enabled: false, + }, }, }, MaxTransactionTime: 5 * time.Minute,