Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 21 additions & 8 deletions backend/app/controllers/log.controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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,
})
}

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
23 changes: 21 additions & 2 deletions backend/app/repositories/telemetry/duckdb/log_record.repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
10 changes: 7 additions & 3 deletions backend/app/repositories/telemetry/shared/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
23 changes: 21 additions & 2 deletions backend/app/repositories/telemetry/sqlite/log_record.repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
Binary file modified examples/devtesting-embedded/devtesting-embedded
Binary file not shown.
2 changes: 0 additions & 2 deletions examples/devtesting-embedded/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
4 changes: 4 additions & 0 deletions examples/devtesting-embedded/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
33 changes: 33 additions & 0 deletions examples/devtesting-embedded/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,74 @@
}
} catch (e) {}
</script>
<style>
#splash {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #71717a;
pointer-events: none;
}
html.dark #splash {
color: #fff;
}
#splash svg {
width: 4.5rem;
height: 4.5rem;
}
#splash .outer-ring {
animation: splash-spin 3s linear infinite;
transform-origin: 250px 250px;
}
#splash .middle-ring {
animation: splash-spin-reverse 2s linear infinite;
transform-origin: 250px 250px;
}
#splash .inner-ring {
animation: splash-spin 1.2s linear infinite;
transform-origin: 250px 250px;
}
@keyframes splash-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes splash-spin-reverse {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div id="splash">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="31.25">
<g class="outer-ring">
<path
d="M250 41.67c115.06 0 208.33 93.27 208.33 208.33c0 37.94-10.15 73.54-27.88 104.17M104.17 101.23A207.71 207.71 0 0 0 41.67 250c0 115.06 93.27 208.33 208.33 208.33c37.94 0 73.54-10.15 104.17-27.88"
/>
</g>
<g class="middle-ring">
<path
d="M104.17 250c0 30.98 9.67 59.71 26.15 83.33M250 104.17a145.83 145.83 0 1 1-62.5 277.63"
/>
</g>
<g class="inner-ring">
<path d="M250 333.33a83.33 83.33 0 0 0 0-166.67" />
</g>
</g>
</svg>
</div>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
Loading
Loading