Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
170f6bc
Adding initial implementation for otel publisher
O-sura Sep 7, 2026
aeacaba
Adding queue drop policy and config vocabulary
O-sura Sep 7, 2026
178f724
Adding Retry and export-failure handling
O-sura Sep 7, 2026
1477d19
Mapping the Publisher self-observability metrics
O-sura Sep 7, 2026
b122338
Adding errorCode, mcp_resouce_uri harnessing and mapping logic
O-sura Sep 8, 2026
177abf3
Fix req and res header mapping issue
O-sura Sep 8, 2026
d4e6e09
Flatten mcp nested attributes for otel publisher
O-sura Sep 8, 2026
d44a15c
Minor bug fix for parsing pesudo headers
O-sura Sep 8, 2026
d4cb996
Minor modifications to the error related analytics attribute mapping
O-sura Sep 10, 2026
9169cb1
fix(otel): preserve zero-valued attributes
O-sura Sep 10, 2026
5b4ab1f
Add proper URL path extraction logic and mapping
O-sura Sep 10, 2026
da16abd
fix(analytics): strip query strings from request paths
O-sura Sep 10, 2026
f92b6f4
Fix otel publisher metrics to count records actually accepted by the …
O-sura Sep 10, 2026
4ba0619
Fix default otel service name misalignment
O-sura Sep 10, 2026
628d917
Add a ceil value for backoff
O-sura Sep 10, 2026
14c9739
Add config to allow insecure transport
O-sura Sep 11, 2026
67282c3
Fix for Reject credentials in endpoint URL userinfo
O-sura Sep 11, 2026
150073c
Adding validation to reject permissive client private-key permissions
O-sura Sep 11, 2026
a5a7d4d
Add helm config mapping and minor mapping issues
O-sura Sep 11, 2026
b2ad860
Sanitize OriginalPath before exporting
O-sura Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gateway/build-manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: v1
policies:
- name: advanced-ratelimit
version: v1.1.2
version: v1.2.0
gomodule: github.com/wso2/gateway-controllers/policies/advanced-ratelimit@v1
- name: analytics-header-filter
version: v1.0.1
Expand Down
91 changes: 91 additions & 0 deletions gateway/configs/config-template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,18 @@ allow_payloads = false
# during validation (with a warning). Prefer setting [collector] directly.
send_request_body = false
send_response_body = false
# Publishers each enabled event is delivered to. Any combination of:
#
# moesif maps the event onto Moesif's event model and posts it with the Moesif
# SDK. Configured under [analytics.publishers.moesif] below.
# otel exports the event to an OpenTelemetry collector as an OTLP log record
# (one record per transaction, event.name=wso2.api.transaction) over
# OTLP/HTTP. From the collector, any OTLP-capable backend — Datadog,
# Splunk, Elastic, Dynatrace, Grafana — is one exporter block away.
# Configured under [analytics.publishers.otel] below.
#
# Publishers are additive and independent: ["moesif", "otel"] delivers every event
# to both. An unknown name is a startup error, not a silent no-op.
enabled_publishers = ["moesif"]

[analytics.publishers.moesif]
Expand All @@ -799,6 +811,85 @@ event_queue_size = 10000
batch_size = 50
timer_wakeup_seconds = 3

[analytics.publishers.otel]
# Full OTLP/HTTP logs URL, including the /v1/logs path. Point it at a collector
# you run, or directly at a vendor's OTLP intake.
endpoint = "http://otel-collector:4318/v1/logs"
# Permits a plaintext http:// endpoint, and is required by the example endpoint
# above. Off by default in code
allow_insecure_transport = true
# Identifies the service
service_name = "gateway-runtime"
# Identifies the deployed build for rollout correlation.
service_version = '{{ env "VERSION" "" }}'
# Record count that triggers an export before flush_interval elapses.
batch_size = 100
# How long a record waits when traffic is too slow to fill a batch.
flush_interval = "5s"
# Records held in memory when the endpoint is slow. Must be >= batch_size.
# Once full, on_queue_full decides which record is dropped — the queue never
# grows unbounded.
queue_capacity = 10000
# Which record is discarded once the queue is full. Analytics is strictly
# downstream of request handling, so one of the two always happens; the choice is
# whether a slow endpoint costs the newest records or the oldest.
# "drop_new" — discard the incoming record, preserving older queued ones.
# Keeps the earliest view of an incident.
# "drop_oldest" — evict the oldest queued record to make room. Keeps the most
# recent traffic, at the cost of the start of the backlog.
# Same two values as traffic_logging.http.on_queue_full.
on_queue_full = "drop_new"
# Bounds a single export attempt.
timeout = "10s"
# Retry attempts after the initial one. Only transport errors, 429 and 5xx are
# retried — any other 4xx means the endpoint rejected the payload's shape, which
# retrying can only amplify. 0 disables retries.
max_retries = 3
# Base delay for exponential backoff, with jitter applied per attempt so replicas
# retrying after a shared outage do not resynchronize into a thundering herd. A
# Retry-After from the endpoint replaces this rather than adding to it.
retry_backoff = "1s"
# Fraction of queue_capacity at which a retrying batch abandons its remaining
# budget and returns to draining. One worker exports, so nothing drains the queue
# while a batch retries: past this depth, retrying to save one batch costs more
# records than it rescues. 0 lets every batch use its full budget.
retry_abort_queue_ratio = 0.5
# "none" (default, matching the OTLP specification's own default) or "gzip". gzip
# trades CPU on the export worker for a large egress reduction — these records are
# verbose JSON — and every OTLP/HTTP receiver is required to support it. Worth
# enabling when the endpoint is across a network you pay for.
compression = "none"

# ==== UNCOMMENT WHEN REQUIRED ================================
# Headers sent on every export, for a vendor OTLP intake that authenticates by
# header. Values are secrets: they are never logged.
# [analytics.publishers.otel.headers]
# X-Moesif-Application-Id = '{{ env "MOESIF_APP_ID" "" }}'
# Extra OTLP resource attributes, e.g. deployment.environment.name.
# [analytics.publishers.otel.resource_attributes]
# "deployment.environment.name" = "prod"

# TLS to the OTLP endpoint. Ignored when `endpoint` is http.
[analytics.publishers.otel.tls]
# PEM bundle used to verify the endpoint's certificate. Empty means the system
# trust store — correct for a vendor's OTLP intake, usually wrong for an
# in-cluster collector fronted by a private CA.
ca_file = ""
# Client certificate and key for mTLS, in PEM form. Both or neither: setting one
# without the other is a startup error rather than a silently unauthenticated
# connection. Both files are read and parsed at startup, so a wrong path or a
# mismatched pair fails immediately instead of at the first export.
#
# cert_file = "/secrets/gateway-runtime/otel-client.crt"
# key_file = "/secrets/gateway-runtime/otel-client.key"
cert_file = ""
key_file = ""
# Disables verification of the endpoint's certificate. Off by default; when on,
# startup logs a warning naming the endpoint, because analytics records carry
# request metadata and, with collector body capture enabled, payloads.
insecure_skip_verify = false


# =============================================================================
# TRAFFIC LOGGING (consumer — enabling it activates the collector)
# =============================================================================
Expand Down
4 changes: 4 additions & 0 deletions gateway/gateway-controller/pkg/xds/translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2730,6 +2730,10 @@ func (t *Translator) createGRPCAccessLog() (*accesslog.AccessLog, error) {
Timeout: durationpb.New(time.Duration(grpcConfig.GRPCRequestTimeout)),
},
},
// populates HTTPResponseProperties.ResponseHeaders
// so responseContentType can resolve for a request that matched no route and
// therefore never reached the policy chain.
AdditionalResponseHeadersToLog: []string{"content-type"},
}

grpcAccessLogAny, err := anypb.New(httpGrpcAccessLog)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"maps"
"net"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -63,6 +64,8 @@ const (
DefaultAnalyticsPublisher = "default"
// MoesifAnalyticsPublisher represents the Moesif analytics publisher.
MoesifAnalyticsPublisher = "moesif"
// OTelAnalyticsPublisher represents the OpenTelemetry analytics publisher
OTelAnalyticsPublisher = "otel"

// HeaderKeys represents the header keys.
RequestHeadersKey = "request_headers"
Expand All @@ -82,6 +85,10 @@ const (
// AIProviderAPIVersionMetadataKey represents the AI provider API version metadata key.
AIProviderAPIVersionMetadataKey string = "ai:providerversion"

// RequestModelIDMetadataKey represents the model named in the request
// (Separate from ModelIDMetadataKey (which resolves to the response model).
RequestModelIDMetadataKey string = "aitoken:requestmodelid"

// UserIDMetadataKey represents the user ID metadata key for analytics.
UserIDMetadataKey string = "x-wso2-user-id"

Expand Down Expand Up @@ -122,6 +129,16 @@ func NewAnalytics(cfg *config.Config) *Analytics {
publishers = append(publishers, publisher)
slog.Info("Moesif publisher added")
}
case OTelAnalyticsPublisher:
publisher, err := analytics_publisher.NewOTel(&analyticsCfg.Publishers.OTel)
if err != nil {
// Fail closed on invalid TLS material to avoid a healthy-looking gateway
// silently exporting nothing. Validation already confirms the material loads.
slog.Error("Failed to initialize the OTel analytics publisher; refusing to start", "error", err)
panic(fmt.Sprintf("otel analytics publisher configuration is unusable: %v", err))
}
publishers = append(publishers, publisher)
slog.Info("OTel publisher added")
default:
slog.Warn("Unknown publisher type", "type", publisherName)
}
Expand Down Expand Up @@ -330,21 +347,26 @@ func (c *Analytics) prepareAnalyticEvent(logEntry *v3.HTTPAccessLogEntry) *dto.E
request := logEntry.GetRequest()
response := logEntry.GetResponse()

// Strip the query once at the source since it is shared across publishers and may contain credentials.
requestPath, _, _ := strings.Cut(request.GetPath(), "?")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// OriginalPath is the pre-rewrite :path, so it carries the client's query too.
originalPath, _, _ := strings.Cut(request.GetOriginalPath(), "?")

// Prepare operation
operation := dto.Operation{}
// operation.APIResourceTemplate = keyValuePairsFromMetadata[APIResourceTemplateKey]
if request != nil {
operation.APIResourceTemplate = logEntry.GetRequest().GetOriginalPath()
operation.APIResourceTemplate = originalPath
operation.APIMethod = logEntry.Request.GetRequestMethod().String()
}

// Prepare target
target := dto.Target{}
target.ResponseCacheHit = false
target.ResponseCacheHit = isCacheHit(logEntry)
if response != nil {
target.TargetResponseCode = int(logEntry.GetResponse().GetResponseCode().Value)
// target.Destination = keyValuePairsFromMetadata[DestinationKey]
target.Destination = logEntry.GetRequest().GetAuthority() + logEntry.GetRequest().GetPath()
target.Destination = logEntry.GetRequest().GetAuthority() + requestPath
target.ResponseCodeDetail = logEntry.GetResponse().GetResponseCodeDetails()
}

Expand Down Expand Up @@ -527,6 +549,9 @@ func (c *Analytics) prepareAnalyticEvent(logEntry *v3.HTTPAccessLogEntry) *dto.E
aiMetadata.LLMCost = parsedLLMCost
}
event.Properties["aiMetadata"] = aiMetadata
if requestModel := keyValuePairsFromMetadata[RequestModelIDMetadataKey]; requestModel != "" {
event.Properties[constants.RequestModelPropertyKey] = requestModel
}

aiTokenUsage := dto.AITokenUsage{}
// Prompt tokens
Expand Down Expand Up @@ -599,6 +624,11 @@ func (c *Analytics) prepareAnalyticEvent(logEntry *v3.HTTPAccessLogEntry) *dto.E
// requestSize is common to all API kinds; mirror responseSize using the Envoy access-log byte count.
if request != nil {
event.Properties["requestSize"] = request.GetRequestBodyBytes()

// Store the concrete request path (without query parameters), separate from the route template.
if requestPath != "" {
event.Properties[constants.RequestPathPropertyKey] = requestPath
}
}

//Adding request and response headers for the analytics event
Expand Down Expand Up @@ -666,6 +696,18 @@ func (c *Analytics) prepareAnalyticEvent(logEntry *v3.HTTPAccessLogEntry) *dto.E
event.Properties["mcpAnalytics"] = mcpAnalytics
}

// Fault classification, last so it sees the finished event.
fault := classifyFault(logEntry)
event.ErrorType = string(fault.ErrorType)
if fault.SubCategory != "" {
event.Error = &dto.Error{
// The client-visible status. The in-development fault flow owns the
// real WSO2 numeric codes and should supply them here instead.
ErrorCode: event.ProxyResponseCode,
ErrorMessage: fault.SubCategory,
}
}

return event
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package analytics
import (
"bytes"
"log/slog"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -1312,3 +1313,122 @@ func createLogEntryWithStreamID(streamID string) *v3.HTTPAccessLogEntry {
},
}
}

// The concrete request path is the only record of what a client actually asked
// for: the route template groups requests for an operation, and for a request
// that matched no route there is no template at all. The query string is cut off
// here rather than at each publisher, because an API key or token in a query
// parameter is an ordinary pattern in this product and publishers forward
// analytics to third parties.
func TestPrepareAnalyticEvent_RequestPathDropsQueryString(t *testing.T) {
for name, tc := range map[string]struct {
path string
want interface{} // nil = the property must be absent
}{
"no query": {"/petstore/pet/12345", "/petstore/pet/12345"},
"single param": {"/petstore/pet/12345?apikey=secret", "/petstore/pet/12345"},
"multiple params": {"/search?q=cat&token=abc123&page=2", "/search"},
"empty query": {"/petstore/pet/12345?", "/petstore/pet/12345"},
"query only": {"?apikey=secret", nil},
"root": {"/", "/"},
"absent path": {"", nil},
"encoded question mark": {"/pet/a%3Fb", "/pet/a%3Fb"},
} {
t.Run(name, func(t *testing.T) {
logEntry := createLogEntryWithMetadata(map[string]string{})
logEntry.Request.Path = tc.path

event := NewAnalytics(&config.Config{}).prepareAnalyticEvent(logEntry)
actual, present := event.Properties[constants.RequestPathPropertyKey]

if tc.want == nil {
if present {
t.Errorf("%s = %v; want absent", constants.RequestPathPropertyKey, actual)
}
return
}
if !present {
t.Fatalf("%s is missing for path %q", constants.RequestPathPropertyKey, tc.path)
}
if actual != tc.want {
t.Errorf("%s = %v, want %v", constants.RequestPathPropertyKey, actual, tc.want)
}
})
}
}

// APIResourceTemplate comes from Envoy's original_path (the pre-rewrite :path,
// which every proxied route here produces via context-path stripping), so it
// carries the client's query string just as Path does. It reaches http.route on
// the OTel publisher, the Moesif uri and the traffic-log path, so it is cut at
// this same single point rather than at each of them.
func TestPrepareAnalyticEvent_APIResourceTemplateDropsQueryString(t *testing.T) {
for name, tc := range map[string]struct {
originalPath string
path string
want string
}{
"no query": {"/petstore/pet/12345", "/pet/12345", "/petstore/pet/12345"},
"single param": {"/petstore/pet/12345?apikey=secret", "/pet/12345", "/petstore/pet/12345"},
"multiple params": {"/search?q=cat&token=abc123", "/search", "/search"},
"empty query": {"/petstore/pet?", "/pet", "/petstore/pet"},
"query only": {"?apikey=secret", "/pet", ""},
"absent original path": {"", "/pet/12345", ""},
"encoded question mark": {"/pet/a%3Fb", "/a%3Fb", "/pet/a%3Fb"},
// The reviewer's case: the rewritten Path is clean while the pre-rewrite
// OriginalPath still carries the credential.
"query only on original path": {"/petstore/pet?apikey=secret", "/pet", "/petstore/pet"},
} {
t.Run(name, func(t *testing.T) {
logEntry := createLogEntryWithMetadata(map[string]string{})
logEntry.Request.OriginalPath = tc.originalPath
logEntry.Request.Path = tc.path

event := NewAnalytics(&config.Config{}).prepareAnalyticEvent(logEntry)

if got := event.Operation.APIResourceTemplate; got != tc.want {
t.Errorf("APIResourceTemplate = %q, want %q", got, tc.want)
}
if strings.Contains(event.Operation.APIResourceTemplate, "?") {
t.Errorf("APIResourceTemplate %q still carries a query string",
event.Operation.APIResourceTemplate)
}
})
}
}

// Target.Destination reaches three consumers — the OTel publisher's
// wso2.upstream.destination, the traffic log's destination field, and the
// target.destination policy expression — so the query string is stripped at this
// single point rather than in each of them. An API key or token in a query
// parameter is an ordinary pattern here, and all three carry records off-box.
func TestPrepareAnalyticEvent_DestinationDropsQueryString(t *testing.T) {
for name, tc := range map[string]struct {
authority string
path string
want string
}{
"no query": {"api.example.com", "/orders/v1.0/listings", "api.example.com/orders/v1.0/listings"},
"credential": {"api.example.com", "/orders/v1.0/listings?apikey=secret", "api.example.com/orders/v1.0/listings"},
"multiple params": {"localhost:8080", "/nofilter/anything?q=1&token=abc", "localhost:8080/nofilter/anything"},
"bare question": {"localhost:8080", "/everything?", "localhost:8080/everything"},
"no path": {"api.example.com", "", "api.example.com"},
} {
t.Run(name, func(t *testing.T) {
logEntry := createLogEntryWithMetadata(map[string]string{})
logEntry.Request.Authority = tc.authority
logEntry.Request.Path = tc.path

event := NewAnalytics(&config.Config{}).prepareAnalyticEvent(logEntry)
if event.Target == nil {
t.Fatal("event.Target is nil")
}
if event.Target.Destination != tc.want {
t.Errorf("Destination = %q, want %q", event.Target.Destination, tc.want)
}
if strings.Contains(event.Target.Destination, "?") {
t.Errorf("Destination %q still carries a query string", event.Target.Destination)
}
})
}
}
Loading
Loading