diff --git a/backend/app/controllers/log.controller.go b/backend/app/controllers/log.controller.go index 42bc6dea4..61e4e810f 100644 --- a/backend/app/controllers/log.controller.go +++ b/backend/app/controllers/log.controller.go @@ -20,9 +20,10 @@ type logController struct{} var LogController = logController{} type LogAttributeFilterRequest struct { - Scope string `json:"scope"` // "resource" | "scope" | "log" - Key string `json:"key"` - Value string `json:"value"` + Scope string `json:"scope"` // "resource" | "scope" | "log" + Key string `json:"key"` + Value string `json:"value"` + Exclude bool `json:"exclude"` } type LogSearchRequest struct { @@ -35,6 +36,9 @@ type LogSearchRequest struct { MinSeverity uint8 `json:"minSeverity"` ServiceName string `json:"serviceName"` TraceId string `json:"traceId"` + SpanId string `json:"spanId"` + ScopeName string `json:"scopeName"` + Body string `json:"body"` DistributedTraceId string `json:"distributedTraceId"` ExcludeTraceId string `json:"excludeTraceId"` AttributeFilters []LogAttributeFilterRequest `json:"attributeFilters"` @@ -43,8 +47,10 @@ type LogSearchRequest struct { // Max time range allowed for body search without any other selector. Keeps a // naïve "find all logs containing 'error' for the past 30 days" query from -// scanning the full body column. -const bodySearchUnscopedMaxRange = 24 * time.Hour +// scanning the full body column. The slack matters: the frontend's "24h" +// preset rounds the range end up to the end of the current minute, so the +// received range is slightly over 24h and must not trip the gate. +const bodySearchUnscopedMaxRange = 24*time.Hour + 5*time.Minute func (l logController) List(c *gin.Context) { projectId, err := middleware.GetProjectId(c) @@ -70,6 +76,9 @@ func (l logController) List(c *gin.Context) { hasSelector := request.MinSeverity > 0 || request.ServiceName != "" || request.TraceId != "" || + request.SpanId != "" || + request.ScopeName != "" || + request.Body != "" || request.DistributedTraceId != "" || len(request.AttributeFilters) > 0 rangeTooWide := request.ToDate.Sub(request.FromDate) > bodySearchUnscopedMaxRange @@ -87,9 +96,10 @@ func (l logController) List(c *gin.Context) { continue } attrFilters = append(attrFilters, telemetry.LogAttributeFilter{ - Scope: f.Scope, - Key: f.Key, - Value: f.Value, + Scope: f.Scope, + Key: f.Key, + Value: f.Value, + Exclude: f.Exclude, }) } @@ -102,6 +112,9 @@ func (l logController) List(c *gin.Context) { MinSeverity: request.MinSeverity, ServiceName: request.ServiceName, TraceId: request.TraceId, + SpanId: request.SpanId, + ScopeName: request.ScopeName, + Body: request.Body, AttributeFilters: attrFilters, OrderBy: request.OrderBy, SortDirection: request.SortDirection, diff --git a/backend/app/repositories/telemetry/attribute_filter_exclude_test.go b/backend/app/repositories/telemetry/attribute_filter_exclude_test.go new file mode 100644 index 000000000..fdf63625e --- /dev/null +++ b/backend/app/repositories/telemetry/attribute_filter_exclude_test.go @@ -0,0 +1,57 @@ +//go:build !telemetry_ch + +package telemetry + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/tracewayapp/traceway/backend/app/models" +) + +func TestLogRecordRepository_Search_ExcludeAttributeFilter(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + record := func(attrs map[string]string) models.LogRecord { + return models.LogRecord{ + Id: uuid.New(), + ProjectId: projectId, + Timestamp: base, + ServiceName: "checkout", + Body: "request handled", + LogAttributes: attrs, + } + } + if err := LogRecordRepository.InsertAsync(ctx, []models.LogRecord{ + record(map[string]string{"http.route": "GET /checkout"}), + record(map[string]string{"http.route": "GET /cart"}), + record(map[string]string{"other.key": "x"}), + }); err != nil { + t.Fatalf("InsertAsync: %v", err) + } + + records, total, err := LogRecordRepository.Search(ctx, LogSearchParams{ + ProjectId: projectId, + FromDate: base.Add(-time.Hour), + ToDate: base.Add(time.Hour), + AttributeFilters: []LogAttributeFilter{{Scope: "log", Key: "http.route", Value: "GET /checkout", Exclude: true}}, + }) + if err != nil { + t.Fatalf("Search (exclude attribute filter): %v", err) + } + // The excluded row is dropped; the row that doesn't carry the attribute at + // all must survive the exclusion. + if total != 2 || len(records) != 2 { + t.Fatalf("exclude attribute filter returned %d rows (total=%d), want 2", len(records), total) + } + for _, r := range records { + if r.LogAttributes["http.route"] == "GET /checkout" { + t.Errorf("excluded record (http.route=GET /checkout) was returned") + } + } +} diff --git a/backend/app/repositories/telemetry/clickhouse/log_record.repository.go b/backend/app/repositories/telemetry/clickhouse/log_record.repository.go index 110269ef4..50a6e3f8f 100644 --- a/backend/app/repositories/telemetry/clickhouse/log_record.repository.go +++ b/backend/app/repositories/telemetry/clickhouse/log_record.repository.go @@ -208,13 +208,31 @@ func (r *logRecordRepository) buildWhere(params shared.LogSearchParams) (string, clauses = append(clauses, "trace_id = ?") args = append(args, params.TraceId) } + if params.SpanId != "" { + clauses = append(clauses, "span_id = ?") + args = append(args, params.SpanId) + } + if params.ScopeName != "" { + clauses = append(clauses, "scope_name = ?") + args = append(args, params.ScopeName) + } + if params.Body != "" { + clauses = append(clauses, "body = ?") + args = append(args, params.Body) + } for _, f := range params.AttributeFilters { col := attrColumn(f.Scope) if col == "" { continue } - clauses = append(clauses, col+"[?] = ?") + op := "=" + if f.Exclude { + // Map columns default missing keys to '', so != also keeps rows + // that don't carry the attribute at all. + op = "!=" + } + clauses = append(clauses, col+"[?] "+op+" ?") args = append(args, f.Key, f.Value) } diff --git a/backend/app/repositories/telemetry/duckdb/log_record.repository.go b/backend/app/repositories/telemetry/duckdb/log_record.repository.go index 243a3b195..b3a5efeca 100644 --- a/backend/app/repositories/telemetry/duckdb/log_record.repository.go +++ b/backend/app/repositories/telemetry/duckdb/log_record.repository.go @@ -235,6 +235,18 @@ func (r *logRecordRepository) buildWhere(params shared.LogSearchParams) (string, clauses = append(clauses, "trace_id = :trace_id") args["trace_id"] = params.TraceId } + if params.SpanId != "" { + clauses = append(clauses, "span_id = :span_id") + args["span_id"] = params.SpanId + } + if params.ScopeName != "" { + clauses = append(clauses, "scope_name = :scope_name") + args["scope_name"] = params.ScopeName + } + if params.Body != "" { + clauses = append(clauses, "body = :body") + args["body"] = params.Body + } for i, f := range params.AttributeFilters { col := attrColumn(f.Scope) @@ -243,8 +255,15 @@ func (r *logRecordRepository) buildWhere(params shared.LogSearchParams) (string, } keyPH := fmt.Sprintf("attr_k%d", i) valPH := fmt.Sprintf("attr_v%d", i) - clauses = append(clauses, - fmt.Sprintf("json_extract_string(%s, '$.\"' || :%s || '\"') = :%s", col, keyPH, valPH)) + if f.Exclude { + // COALESCE keeps rows that don't carry the attribute at all + // (json_extract_string yields NULL there, and NULL != value is NULL). + clauses = append(clauses, + fmt.Sprintf("COALESCE(json_extract_string(%s, '$.\"' || :%s || '\"'), '') != :%s", col, keyPH, valPH)) + } else { + clauses = append(clauses, + fmt.Sprintf("json_extract_string(%s, '$.\"' || :%s || '\"') = :%s", col, keyPH, valPH)) + } args[keyPH] = f.Key args[valPH] = f.Value } diff --git a/backend/app/repositories/telemetry/shared/types.go b/backend/app/repositories/telemetry/shared/types.go index b69fa3aa6..cf71dadf9 100644 --- a/backend/app/repositories/telemetry/shared/types.go +++ b/backend/app/repositories/telemetry/shared/types.go @@ -28,9 +28,10 @@ type FiredNotification struct { // query (Map columns with bloom-filter indexes on ClickHouse, JSON on // SQLite/DuckDB). type LogAttributeFilter struct { - Scope string - Key string - Value string + Scope string + Key string + Value string + Exclude bool } type LogSearchParams struct { @@ -43,6 +44,9 @@ type LogSearchParams struct { ServiceName string TraceId string TraceIds []string + SpanId string + ScopeName string + Body string AttributeFilters []LogAttributeFilter OrderBy string SortDirection string diff --git a/backend/app/repositories/telemetry/sqlite/log_record.repository.go b/backend/app/repositories/telemetry/sqlite/log_record.repository.go index 758c1db2f..f747a702e 100644 --- a/backend/app/repositories/telemetry/sqlite/log_record.repository.go +++ b/backend/app/repositories/telemetry/sqlite/log_record.repository.go @@ -220,6 +220,18 @@ func (r *logRecordRepository) buildWhere(params shared.LogSearchParams) (string, clauses = append(clauses, "trace_id = :trace_id") args["trace_id"] = params.TraceId } + if params.SpanId != "" { + clauses = append(clauses, "span_id = :span_id") + args["span_id"] = params.SpanId + } + if params.ScopeName != "" { + clauses = append(clauses, "scope_name = :scope_name") + args["scope_name"] = params.ScopeName + } + if params.Body != "" { + clauses = append(clauses, "body = :body") + args["body"] = params.Body + } for i, f := range params.AttributeFilters { col := attrColumn(f.Scope) @@ -228,8 +240,15 @@ func (r *logRecordRepository) buildWhere(params shared.LogSearchParams) (string, } keyPH := fmt.Sprintf("attr_k%d", i) valPH := fmt.Sprintf("attr_v%d", i) - clauses = append(clauses, - fmt.Sprintf("json_extract(%s, '$.\"' || :%s || '\"') = :%s", col, keyPH, valPH)) + if f.Exclude { + // COALESCE keeps rows that don't carry the attribute at all + // (json_extract yields NULL there, and NULL != value is NULL). + clauses = append(clauses, + fmt.Sprintf("COALESCE(json_extract(%s, '$.\"' || :%s || '\"'), '') != :%s", col, keyPH, valPH)) + } else { + clauses = append(clauses, + fmt.Sprintf("json_extract(%s, '$.\"' || :%s || '\"') = :%s", col, keyPH, valPH)) + } args[keyPH] = f.Key args[valPH] = f.Value } diff --git a/examples/devtesting-embedded/devtesting-embedded b/examples/devtesting-embedded/devtesting-embedded index abc454fce..01aec00c3 100755 Binary files a/examples/devtesting-embedded/devtesting-embedded and b/examples/devtesting-embedded/devtesting-embedded differ diff --git a/examples/devtesting-embedded/go.mod b/examples/devtesting-embedded/go.mod index 43d8ff17e..18397ba5a 100644 --- a/examples/devtesting-embedded/go.mod +++ b/examples/devtesting-embedded/go.mod @@ -156,6 +156,4 @@ require ( replace ( github.com/tracewayapp/traceway/backend => /Users/dusanstanojevic/Documents/workspace/traceway/backend github.com/tracewayapp/traceway/cli => /Users/dusanstanojevic/Documents/workspace/traceway/cli - go.tracewayapp.com => /Users/dusanstanojevic/Documents/workspace/go-client - go.tracewayapp.com/tracewaygin => /Users/dusanstanojevic/Documents/workspace/go-client/tracewaygin ) diff --git a/examples/devtesting-embedded/go.sum b/examples/devtesting-embedded/go.sum index 57b635b97..ad918dd05 100644 --- a/examples/devtesting-embedded/go.sum +++ b/examples/devtesting-embedded/go.sum @@ -355,6 +355,10 @@ go.opentelemetry.io/proto/otlp/collector/profiles/v1development v0.3.0 h1:AWBLtJ go.opentelemetry.io/proto/otlp/collector/profiles/v1development v0.3.0/go.mod h1:kCdbxyM9kfmv3v4ZQzZ2pACxoQ6lE0IsjP+UcZwQnr4= go.opentelemetry.io/proto/otlp/profiles/v1development v0.3.0 h1:ZQs05qo3Yh4KUHeVH6v89xErwmsvgA/cLX2/w5Ikp+k= go.opentelemetry.io/proto/otlp/profiles/v1development v0.3.0/go.mod h1:3iiRVKaCfVo0UI1ZaSMm5WbCBbINRqVlD9SUmvyBNrY= +go.tracewayapp.com v1.0.4 h1:n9O1OQDRDNPwCBnb7kNd+DutcVoPj9uLn+c9n4M7qzM= +go.tracewayapp.com v1.0.4/go.mod h1:sFMbWkA2TbT1OW6Hx+6jMDum2yt96irUXpC5lymblyc= +go.tracewayapp.com/tracewaygin v1.0.2 h1:MRAvtOKz0zceoe1Ee7pl5eciSerc+gIogC7oWJaKQf8= +go.tracewayapp.com/tracewaygin v1.0.2/go.mod h1:p/26TIhpfte86Gp3QJpA8Q72pJShFXX1jogIiKG2LXQ= 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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/examples/devtesting-embedded/main.go b/examples/devtesting-embedded/main.go index eea6ed7c1..c6ba6dd6e 100644 --- a/examples/devtesting-embedded/main.go +++ b/examples/devtesting-embedded/main.go @@ -326,6 +326,38 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + router.GET("/api/test-template-logs", func(c *gin.Context) { + ctx := c.Request.Context() + for i := 0; i < 3; i++ { + requestId := uuid.NewString() + backendSvc.log(ctx, otellog.SeverityDebug, "DEBUG", + "Incoming call received.ProviderClientId: {ProviderClientId}, CallerId: {CallerId}, Method: {Method}, RequestId: {RequestId}", + otellog.String("ProviderClientId", "[Devices, dusan-macbook, 1, rpc]"), + otellog.String("CallerId", "[Portal, web-6f2c, 0, rpc]"), + otellog.String("Method", "EnterManualMode"), + otellog.String("RequestId", requestId), + ) + backendSvc.log(ctx, otellog.SeverityDebug, "DEBUG", + "Call handled successfully.ProviderClientId: {ProviderClientId},RequestId: {RequestId},Method: {MethodIdentity}", + otellog.String("ProviderClientId", "[Devices, dusan-macbook, 1, rpc]"), + otellog.String("RequestId", requestId), + otellog.String("MethodIdentity", "MethodIdentity { Namespace = MqttComm.Devices, Name = EnterManualMode }"), + ) + } + // Format spec after the name + a non-string attribute value. + backendSvc.log(ctx, otellog.SeverityWarn, "WARN", + "Slow provider response.Provider: {Provider}, Elapsed: {ElapsedMs:N0}ms", + otellog.String("Provider", "Devices"), + otellog.Int("ElapsedMs", 1874), + ) + // {MaxAttempts} has no matching attribute and must render literally. + backendSvc.log(ctx, otellog.SeverityInfo, "INFO", + "Retrying delivery.Attempt: {Attempt} of {MaxAttempts}", + otellog.String("Attempt", "2"), + ) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + router.GET("/api/test-sse", func(c *gin.Context) { span := trace.SpanFromContext(c.Request.Context()) span.SetAttributes(attribute.Bool("traceway.is_stream", true)) @@ -360,6 +392,7 @@ func main() { fmt.Printf(" curl http://localhost:%d/api/test-distributed-logs\n", appPort) fmt.Printf(" curl http://localhost:%d/api/test-long-attributes\n", appPort) fmt.Printf(" curl http://localhost:%d/api/test-long-log-attributes\n", appPort) + fmt.Printf(" curl http://localhost:%d/api/test-template-logs\n", appPort) fmt.Println() fmt.Println(" Streaming endpoints (is_stream — expect a 'Stream' badge):") fmt.Printf(" curl -N http://localhost:%d/api/test-sse\n", appPort) diff --git a/frontend/src/app.html b/frontend/src/app.html index 386b2ce7f..1e7fb2d38 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -18,9 +18,74 @@ } } catch (e) {} + %sveltekit.head%
+