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
42 changes: 36 additions & 6 deletions .github/workflows/benchmark-processor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ name: benchmark-processor
on:
workflow_dispatch:
inputs:
language:
description: 'Symbolication language: js (5 impls incl. Honeycomb), dart (traceway-dart-mem/disk), or both'
required: true
default: 'js'
type: choice
options:
- js
- dart
- both
scenarios:
description: 'Scenarios to run'
required: true
Expand Down Expand Up @@ -84,6 +93,7 @@ jobs:
./bin/builder --config manifest-traceway.yaml --skip-compilation
cd build-traceway && CGO_ENABLED=1 go build -tags oxc -o otelcol-bench-traceway .
- name: Build honeycomb collector
if: inputs.language != 'dart'
working-directory: benchmarks/processor
run: |
GOBIN=$PWD/bin go install go.opentelemetry.io/collector/cmd/builder@v0.143.0
Expand All @@ -97,32 +107,52 @@ jobs:
cd loadgen && go mod tidy && go build -o loadgen . && cd ..
cd corpusgen && go build -o corpusgen .
- name: Build node-app bundle
if: inputs.language != 'dart'
working-directory: testing/symbolication/node-app
run: npm install && npm run build
- name: Assemble artifacts
working-directory: benchmarks/processor
run: |
mkdir -p artifacts
cp build-traceway/otelcol-bench-traceway artifacts/
cp build-honeycomb/otelcol-bench-honeycomb artifacts/
cp "$(find ~/go/pkg/mod/github.com/honeycombio -name 'libsymbolic_cabi.so' -path '*linux_x86_64*' | head -1)" artifacts/
cp drain/target/release/drain artifacts/
cp loadgen/loadgen corpusgen/corpusgen artifacts/
cp config-traceway.yaml config-honeycomb.yaml rss-sampler.sh artifacts/
cp ../../testing/symbolication/node-app/dist/app.mjs ../../testing/symbolication/node-app/dist/app.mjs.map artifacts/
cp config-traceway.yaml rss-sampler.sh artifacts/
cp -r seeds artifacts/
if [ -f build-honeycomb/otelcol-bench-honeycomb ]; then
cp build-honeycomb/otelcol-bench-honeycomb artifacts/
cp "$(find ~/go/pkg/mod/github.com/honeycombio -name 'libsymbolic_cabi.so' -path '*linux_x86_64*' | head -1)" artifacts/
cp config-honeycomb.yaml artifacts/
cp ../../testing/symbolication/node-app/dist/app.mjs ../../testing/symbolication/node-app/dist/app.mjs.map artifacts/
fi
- uses: actions/upload-artifact@v4
with:
name: bench-artifacts
path: benchmarks/processor/artifacts/

setup:
runs-on: ubuntu-latest
outputs:
impls: ${{ steps.m.outputs.impls }}
steps:
- id: m
run: |
case "${{ inputs.language }}" in
js) impls='["honeycomb","traceway-oxc-mem","traceway-oxc-disk","traceway-goja-mem","traceway-goja-disk"]' ;;
dart) impls='["traceway-dart-mem","traceway-dart-disk"]' ;;
both) impls='["honeycomb","traceway-oxc-mem","traceway-oxc-disk","traceway-goja-mem","traceway-goja-disk","traceway-dart-mem","traceway-dart-disk"]' ;;
*) echo "unknown language ${{ inputs.language }}" >&2; exit 1 ;;
esac
echo "impls=$impls" >> "$GITHUB_OUTPUT"

bench:
needs: build
needs: [build, setup]
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 1
matrix:
impl: [honeycomb, traceway-oxc-mem, traceway-oxc-disk, traceway-goja-mem, traceway-goja-disk]
impl: ${{ fromJson(needs.setup.outputs.impls) }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
Expand Down
77 changes: 25 additions & 52 deletions backend/app/controllers/clientcontrollers/client.controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ import (

type clientController struct{}

// isEmptyRaw reports whether a json.RawMessage carries no meaningful payload —
// nil, blank, `null`, `[]`, or `{}` all count as empty. Used to drop session
// recordings that would otherwise just be wasted S3 writes.
func isEmptyRaw(r json.RawMessage) bool {
if len(r) == 0 {
return true
Expand Down Expand Up @@ -98,20 +95,14 @@ func (e clientController) Report(c *gin.Context) {

var recordingsWork []recordings.Job

// Map frontend sessionRecordingId → backend-generated exception UUID.
// Used only for the legacy exception-bound recording path; the always-on
// session linkage uses the SDK-supplied sessionId UUID directly (it IS
// the session row id by design, so no in-request map is needed).
recordingIdToExceptionId := map[string]uuid.UUID{}

convertSpan := traceway.StartSpan(c, "report.convert_frames")
for _, cf := range request.CollectionFrames {
for _, cs := range cf.Sessions {
s := cs.ToSession(request.AppVersion, request.ServerName)
s.ProjectId = projectId
// The SDK can't see the public-facing IP; we stamp it server-side
// into the attributes blob so the dashboard surfaces it alongside
// browser/url/viewport collected by the SDK.

if clientIP := c.ClientIP(); clientIP != "" {
if s.Attributes == nil {
s.Attributes = map[string]string{}
Expand Down Expand Up @@ -142,13 +133,16 @@ func (e clientController) Report(c *gin.Context) {
spansToInsert = append(spansToInsert, span)
}
}
resolveJs := project != nil && isJsFramework(project.Framework) && project.SourceMapToken != nil
resolveJs := project != nil && project.SourceMapToken != nil && jsFrameworks[project.Framework]
resolveDart := project != nil && project.SourceMapToken != nil && project.Framework == "flutter"

resolveSpan := traceway.StartSpan(c, "report.resolve_stack_traces")
for _, cst := range cf.StackTraces {
resolvedStackTrace := cst.StackTrace
if resolveJs {
resolvedStackTrace = services.ResolveStackTrace(c, projectId, cst.StackTrace, cst.DebugIds)
} else if resolveDart {
resolvedStackTrace = services.ResolveDartStackTrace(c, projectId, cst.StackTrace)
}
est := cst.ToExceptionStackTrace(ComputeExceptionHash(resolvedStackTrace, cst.IsMessage), request.AppVersion, request.ServerName)
est.StackTrace = resolvedStackTrace
Expand All @@ -157,10 +151,7 @@ func (e clientController) Report(c *gin.Context) {
if cst.SessionRecordingId != nil {
recordingIdToExceptionId[*cst.SessionRecordingId] = est.Id
}
// The SDK-provided session UID is the session row id by design, so
// parse it directly. The parent `sessions` row may have been
// upserted in an earlier request — we don't require it in this
// batch.

if cst.SessionId != nil {
if parsed, err := uuid.Parse(*cst.SessionId); err == nil {
est.SessionId = &parsed
Expand All @@ -177,10 +168,7 @@ func (e clientController) Report(c *gin.Context) {
}

for _, sr := range cf.SessionRecordings {
// A recording can be exception-bound (legacy path), session-bound
// (always-on path), or both. Exception linkage requires the
// exception to be in this batch; session linkage doesn't, since
// the SDK-provided sessionId is the session row id by design.

var exceptionId uuid.UUID
if sr.ExceptionId != "" {
if id, ok := recordingIdToExceptionId[sr.ExceptionId]; ok {
Expand Down Expand Up @@ -323,27 +311,24 @@ func (e clientController) Report(c *gin.Context) {
}

var (
errorMessageRe = regexp.MustCompile(`(?m)^(\*?[\w.]+):\s*.+`)
causedByRe = regexp.MustCompile(`(?m)^(Caused by:\s*[\w.$]+):\s*.+`)
jsFuncLineRe = regexp.MustCompile(`(?m)^( {0,4})(.+)\(\)(\n {4}.+:\d+:\d+)$`)
urlOriginRe = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^/\s]*`)
absolutePathRe = regexp.MustCompile(`/[^\s:]+/([^/\s:]+:\d+)`)
// Engines disagree on column conventions for the same throw, so columns
// only disambiguate when everything sits on line 1 (minified bundles with
// no matching source map). For any other line the column is dropped from
// the hash so resolved frames group across engines.
errorMessageRe = regexp.MustCompile(`(?m)^(\*?[\w.]+):\s*.+`)
causedByRe = regexp.MustCompile(`(?m)^(Caused by:\s*[\w.$]+):\s*.+`)
jsFuncLineRe = regexp.MustCompile(`(?m)^( {0,4})(.+)\(\)(\n {4}.+:\d+:\d+)$`)
urlOriginRe = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^/\s]*`)
absolutePathRe = regexp.MustCompile(`/[^\s:]+/([^/\s:]+:\d+)`)

laterLineColRe = regexp.MustCompile(`(?m)^(\s*.+:(?:[2-9]|[1-9]\d+)):\d+$`)
versionRe = regexp.MustCompile(`@v[\d.]+`)
hexRe = regexp.MustCompile(`0x[0-9a-fA-F]+`)
uuidRe = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
largeNumberRe = regexp.MustCompile(`(^|[^:\d])(\d{5,})($|[^\d])`)
emailRe = regexp.MustCompile(`[\w.\-]+@[\w.\-]+\.\w+`)
ipRe = regexp.MustCompile(`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?`)
goroutineRe = regexp.MustCompile(`goroutine \d+`)
javaLineNumRe = regexp.MustCompile(`\((\w[\w.$]*\.(?:java|kt|scala)):\d+\)`)
javaEllipsisRe = regexp.MustCompile(`\.\.\. \d+ more`)
spacesRe = regexp.MustCompile(`[ \t]+`)
newlinesRe = regexp.MustCompile(`\n+`)
versionRe = regexp.MustCompile(`@v[\d.]+`)
hexRe = regexp.MustCompile(`0x[0-9a-fA-F]+`)
uuidRe = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
largeNumberRe = regexp.MustCompile(`(^|[^:\d])(\d{5,})($|[^\d])`)
emailRe = regexp.MustCompile(`[\w.\-]+@[\w.\-]+\.\w+`)
ipRe = regexp.MustCompile(`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?`)
goroutineRe = regexp.MustCompile(`goroutine \d+`)
javaLineNumRe = regexp.MustCompile(`\((\w[\w.$]*\.(?:java|kt|scala)):\d+\)`)
javaEllipsisRe = regexp.MustCompile(`\.\.\. \d+ more`)
spacesRe = regexp.MustCompile(`[ \t]+`)
newlinesRe = regexp.MustCompile(`\n+`)
)

func ComputeExceptionHash(stackTrace string, isMessage bool) string {
Expand All @@ -353,10 +338,7 @@ func ComputeExceptionHash(stackTrace string, isMessage bool) string {
normalized = causedByRe.ReplaceAllString(normalized, "$1")
normalized = errorMessageRe.ReplaceAllString(normalized, "$1")
normalized = jsFuncLineRe.ReplaceAllString(normalized, "${1}<fn>${3}")
// Bundle URLs (https://host/assets/app.js, file:///srv/app.mjs,
// webpack://app/./src/x.js) must group with the same frames reported
// as bare filenames by the JS SDK, so the origin goes first and the
// remaining /path is reduced by absolutePathRe like any other path.

normalized = urlOriginRe.ReplaceAllString(normalized, "")
normalized = absolutePathRe.ReplaceAllString(normalized, "$1")
normalized = laterLineColRe.ReplaceAllString(normalized, "$1")
Expand Down Expand Up @@ -390,15 +372,6 @@ var jsFrameworks = map[string]bool{
"react-native": true,
}

func isJsFramework(framework string) bool {
return jsFrameworks[framework]
}

// Browser-only frameworks. Spans arriving for these projects are page-load
// noise (web vitals, fetch spans), never server endpoints, so OTel trace
// ingest only extracts exceptions for them. Fullstack frameworks (nextjs,
// remix) and JS backends (nestjs, express, hono, cloudflare) are excluded
// because they emit legitimate SERVER spans.
var frontendJsFrameworks = map[string]bool{
"react": true,
"svelte": true,
Expand Down
38 changes: 38 additions & 0 deletions backend/app/controllers/clientcontrollers/dart_hash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package clientcontrollers

import "testing"

func TestDartSymbolicatedHashIsMachineIndependent(t *testing.T) {
alice := "PaymentDeclinedException: card declined for $30.59\n" +
"#0 chargeCard (/Users/alice/app/lib/main.dart:20:3)\n" +
"#1 applyTax (/Users/alice/app/lib/main.dart:30:10)\n" +
"#2 checkout (/Users/alice/app/lib/main.dart:35:10)"

bob := "PaymentDeclinedException: card declined for $42.00\n" +
"#0 chargeCard (/home/bob/ci/workspace/lib/main.dart:20:3)\n" +
"#1 applyTax (/home/bob/ci/workspace/lib/main.dart:30:10)\n" +
"#2 checkout (/home/bob/ci/workspace/lib/main.dart:35:10)"

if ComputeExceptionHash(alice, false) != ComputeExceptionHash(bob, false) {
t.Errorf("same crash on different machines should hash the same")
}

other := "PaymentDeclinedException: card declined for $30.59\n" +
"#0 refund (/Users/alice/app/lib/main.dart:88:3)"
if ComputeExceptionHash(alice, false) == ComputeExceptionHash(other, false) {
t.Errorf("different stacks should not share a hash")
}
}

func TestDartOffsetFrameHashStable(t *testing.T) {

report1 := "PaymentDeclinedException: card declined for $30.59\n" +
"#0 _kDartIsolateSnapshotInstructions+141e6b\n" +
"#1 _kDartIsolateSnapshotInstructions+141d9b"
report2 := "PaymentDeclinedException: card declined for $99.10\n" +
"#0 _kDartIsolateSnapshotInstructions+141e6b\n" +
"#1 _kDartIsolateSnapshotInstructions+141d9b"
if ComputeExceptionHash(report1, false) != ComputeExceptionHash(report2, false) {
t.Errorf("same crash (same offsets, different message values) should hash the same")
}
}
2 changes: 1 addition & 1 deletion backend/app/controllers/otelcontrollers/otel.controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"github.com/tracewayapp/traceway/backend/app/repositories"
"github.com/tracewayapp/traceway/backend/app/services"
"github.com/tracewayapp/traceway/backend/app/storage"
"github.com/tracewayapp/traceway/backend/app/symbolicator/jsstack"
"github.com/tracewayapp/traceway/backend/app/symbolicator/sourcemap/jsstack"
traceway "go.tracewayapp.com"
)

Expand Down
Loading
Loading