Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
49bb335
fix: vite.config.ts not reading the proper path
dusanstanojeviccs Jul 20, 2026
733aeb3
progress
dusanstanojeviccs Jul 21, 2026
4dabeb0
Update SKILL.md
dusanstanojeviccs Jul 21, 2026
085ed60
perf: background write batcher for DuckDB telemetry ingest
jstojiljkovic Jul 22, 2026
ce7bc85
bench: throughput 2026-07-22
Jul 22, 2026
eaff132
perf: shard duckdb table writers, pool OTLP decode scratch
jstojiljkovic Jul 22, 2026
8b03fc2
bench: throughput 2026-07-22
Jul 22, 2026
e489640
#270 #265 #264
dusanstanojeviccs Jul 23, 2026
d74a011
Merge pull request #271 from tracewayapp/minor-cleanup
dusanstanojeviccs Jul 23, 2026
91c747b
v to 1.9.3
github-actions[bot] Jul 23, 2026
fdbbbd6
release: backend v1.9.3
github-actions[bot] Jul 23, 2026
4d7304e
chore: v3 optimization
jstojiljkovic Jul 23, 2026
dc7bd36
Merge branch 'feat/write-optimization' of github.com:tracewayapp/trac…
jstojiljkovic Jul 23, 2026
e2bddf5
bench: throughput 2026-07-23
Jul 23, 2026
6d018c4
perf: memoize identical resource/scope attribute marshals in logs con…
jstojiljkovic Jul 23, 2026
f16312c
Merge branch 'main' into dashboards-as-code
dusanstanojeviccs Jul 23, 2026
0cc0837
bench: throughput 2026-07-23
Jul 23, 2026
0180a01
bench: read-probe 2026-07-24
Jul 24, 2026
3d0d9bd
minor ui fixes
dusanstanojeviccs Jul 24, 2026
8ce2a60
docs and website updated
dusanstanojeviccs Jul 24, 2026
b0386c7
fix: harden write-batcher failure paths and dedupe ingest load shedding
jstojiljkovic Jul 24, 2026
e6dd6ec
cli work
dusanstanojeviccs Jul 24, 2026
a37382b
Merge branch 'main' into dashboards-as-code
dusanstanojeviccs Jul 24, 2026
e07afa3
cleanup
dusanstanojeviccs Jul 24, 2026
adb7a8f
Merge pull request #269 from tracewayapp/dashboards-as-code
dusanstanojeviccs Jul 24, 2026
0f739ad
fix: better alert/callouts
dusanstanojeviccs Jul 24, 2026
9c6abb0
v to 1.9.4
github-actions[bot] Jul 24, 2026
2f10c59
release: backend v1.9.4
github-actions[bot] Jul 24, 2026
030a9b7
Merge pull request #268 from tracewayapp/feature/duckdb-benchmark-blog
dusanstanojeviccs Jul 24, 2026
fbf8904
stupid fix: wrong url as the default
dusanstanojeviccs Jul 24, 2026
5195aaa
fix: better skill install step
dusanstanojeviccs Jul 24, 2026
66021a6
v to 1.9.5
github-actions[bot] Jul 24, 2026
f89e044
release: backend v1.9.5
github-actions[bot] Jul 24, 2026
2508b5e
bench: throughput 2026-07-24
Jul 24, 2026
c6c9698
fix: cli skill drift
dusanstanojeviccs Jul 27, 2026
b1be687
Merge branch 'main' into feat/write-optimization
jstojiljkovic Jul 27, 2026
e500d27
fix: flush duckdb write queues on graceful shutdown
jstojiljkovic Jul 29, 2026
7505624
perf: goccy/go-json for attribute marshal on the duckdb write path
jstojiljkovic Jul 29, 2026
73e2aed
bench: throughput 2026-07-29
Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
60 changes: 40 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Traceway is an error tracking and monitoring platform consisting of:

- **No pointless comments**: Do not add comments that simply describe what the code does. The code should be self-explanatory. Only add comments when explaining non-obvious "why" decisions.
- **No `py-4` in dialog form content**: Do not add `py-4` on the content wrapper inside `AlertDialog` or `Dialog` components — it creates too much blank space between the form and the action buttons.
- **Dialog button labels & toasts**: For form dialogs, use descriptive button labels with icons instead of generic "Create"/"Update". The `{Entity}` is always a platform entity, capitalized (Project, Widget, Widget Group, Channel, Rule, Token, Invitation, ...). Create actions: `<Plus icon> New {Entity}` with `variant="success"`. Update actions: `<Check icon> Update {Entity}` with the default (primary) variant. Delete/revoke/remove confirm buttons: `<Trash2 icon> Delete {Entity}` (or `Revoke {Entity}` / `Remove {Entity}`) with `variant="destructive"`. After success, show `toast.success('Successfully created the {Entity}', { position: 'top-center' })` for creates and `'Successfully updated the {Entity}'` for updates. The button should only be `disabled` during the loading state — never disable it to enforce validation; let the backend return 422 and show the error in the dialog instead.
- **Dialog button labels & toasts**: For form dialogs, use descriptive button labels with icons instead of generic "Create"/"Update". The `{Entity}` is always a platform entity, capitalized (Project, Widget, Dashboard, Channel, Rule, Token, Invitation, ...). Create actions: `<Plus icon> New {Entity}` with `variant="success"`. Update actions: `<Check icon> Update {Entity}` with the default (primary) variant. Delete/revoke/remove confirm buttons: `<Trash2 icon> Delete {Entity}` (or `Revoke {Entity}` / `Remove {Entity}`) with `variant="destructive"`. After success, show `toast.success('Successfully created the {Entity}', { position: 'top-center' })` for creates and `'Successfully updated the {Entity}'` for updates. The button should only be `disabled` during the loading state — never disable it to enforce validation; let the backend return 422 and show the error in the dialog instead.

---

Expand Down Expand Up @@ -488,7 +488,7 @@ const handleClick = createRowClickHandler('/issues/abc123', 'preset', 'from', 't
/endpoints/[endpoint] Single endpoint details
/tasks Background tasks list
/tasks/[task] Single task details
/metrics System metrics dashboard (CPU, memory, etc.)
/dashboards Dashboards page (tabs of org dashboards; /metrics redirects here)
/connection SDK integration guide
```

Expand Down Expand Up @@ -620,22 +620,40 @@ backend/
| GET | `/api/metrics/discover/tags` | App | Discover metric tags |
| PUT | `/api/metrics/registry` | App+Write | Update metric registry entry |

**Widget Groups & Widgets**
**Dashboards & Templates**

Dashboards are org-owned JSON documents (`{schemaVersion, widgets: [{id, title, widgetType, config}]}`, widget ids are server-generated `w_xxxxxxxx` strings, array order = display order) applied to projects via `project_dashboards`. Dashboard mutations require org role above `readonly` (checked in-handler); project-scoped routes (list/star/reorder/populate) use the standard middleware chains; apply/unapply also check the effective role of each affected project. The old per-project widget-group tables are converted once at startup by `backfill.RunDashboards` (advisory-locked on PG) and retained for rollback.

| Method | Endpoint | Auth | Purpose |
|--------|----------|------|---------|
| GET | `/api/widget-groups` | App | List widget groups |
| POST | `/api/widget-groups` | App+Write | Create widget group |
| GET | `/api/widget-groups/:id` | App | Get group with widgets |
| PUT | `/api/widget-groups/:id` | App+Write | Update widget group |
| DELETE | `/api/widget-groups/:id` | App+Write | Delete widget group |
| POST | `/api/widget-groups/:id/widgets` | App+Write | Add widget |
| PUT | `/api/widget-groups/:id/widgets/:wid` | App+Write | Update widget |
| PUT | `/api/widget-groups/:id/reorder` | App+Write | Reorder widgets (explicit id order) |
| PUT | `/api/widget-groups/:id/widgets/:wid/star` | App+Write | Star/unstar widget for the homepage |
| DELETE | `/api/widget-groups/:id/widgets/:wid` | App+Write | Delete widget |
| GET | `/api/widget-groups/starred` | App | List starred widgets with homepage layout |
| PUT | `/api/starred-widgets/reorder` | App+Write | Reorder homepage starred widgets (explicit id order) |
| PUT | `/api/starred-widgets/:wid` | App+Write | Update homepage layout (colSpan/size) of a starred widget |
| GET | `/api/dashboards` | App | Dashboards applied to the project (tab order) |
| GET | `/api/dashboards/library` | App | All dashboards across the user's orgs with applied project ids |
| POST | `/api/dashboards` | App | Create in org (auto-applies to current project unless `applyToProjectIds` given) |
| GET | `/api/dashboards/:id` | App | Meta + widgets (+ per-project `isStarred`, `appliedProjectIds`) |
| PUT | `/api/dashboards/:id` | App | Update name/description and/or full `definition` (the as-code path) |
| DELETE | `/api/dashboards/:id` | App | Delete everywhere (assignments + stars cascade) |
| PUT | `/api/dashboards/:id/apply` | App | Set the full project assignment list |
| DELETE | `/api/dashboards/:id/apply/:projectId` | App | Unassign from one project |
| POST | `/api/dashboards/:id/copy` | App | Copy (also cross-org) with optional apply |
| PUT | `/api/dashboards/reorder` | App+Write | Tab order for a project (explicit id order) |
| POST | `/api/dashboards/:id/widgets` | App | Add widget |
| PUT | `/api/dashboards/:id/widgets/reorder` | App | Reorder widgets (explicit id order) |
| PUT | `/api/dashboards/:id/widgets/:wid` | App | Update widget |
| DELETE | `/api/dashboards/:id/widgets/:wid` | App | Delete widget (+ its stars) |
| PUT | `/api/dashboards/:id/widgets/:wid/star` | App+Write | Star/unstar for the project homepage |
| GET | `/api/dashboards/starred` | App | Starred widgets with homepage layout |
| PUT | `/api/starred-widgets/reorder` | App+Write | Reorder homepage starred widgets (`{ids}` = starred row ids) |
| PUT | `/api/starred-widgets/:id` | App+Write | Update homepage layout (colSpan/size) |
| GET | `/api/dashboards/:id/export` | App | Export one dashboard as JSON |
| GET | `/api/dashboards/export?organizationId=` | App | Export the org bundle |
| POST | `/api/dashboards/import` | App | Import doc/bundle (`mode: create\|upsert`, upsert matches by name) |
| POST | `/api/dashboards/import/grafana` | App | Convert a Grafana export (best-effort, returns `warnings[]`) |
| GET | `/api/dashboard-templates` | App | Marketplace list/search (`search`, `category` params) |
| POST | `/api/dashboard-templates/:key/install` | App | Copy a template into the org and apply |
| POST | `/api/dashboards/populate-defaults` | App+Write | Install the framework-default template set for an empty project |
| GET | `/api/metrics/discover/org` | App | Metric names across all org projects (command palette) |

Templates are DB rows seeded by migrations (`traceway-otel-agent` for the OTel host agent, `golang` for Go SDK apps, `traceway-clickhouse`/`traceway-duckdb` for the telemetry stores of a monitored Traceway instance; SQLite emits no store-specific metrics so it has no template); cloud can insert more rows without a release. The OTLP metric ingest allowlists per-resource grouping tags (`container.name`, `k8s.pod.name`, `k8s.node.name`, `postgresql.database.name`, ...) in `otelcontrollers/metric_converter.go` for custom widgets.

**Endpoints**
| Method | Endpoint | Auth | Purpose |
Expand Down Expand Up @@ -741,9 +759,11 @@ func (c *ReportController) Report(ctx *gin.Context) {
| `invitations` | Team invitations with token, role, expiry |
| `source_maps` | Uploaded source map files (project, version, storage key) |
| `metric_registry` | Custom metric definitions (type, unit, description) |
| `widget_groups` | Dashboard widget groups (name, default flag) |
| `widget_group_widgets` | Individual widgets within groups (type, config, position) |
| `starred_widgets` | Homepage layout for starred widgets (position, col_span, size per widget) |
| `dashboards` | Org-owned dashboards (name, JSONB definition with widgets, template provenance) |
| `project_dashboards` | Which projects show a dashboard, and tab order |
| `dashboard_templates` | Marketplace templates (key, category, definition), seeded by migrations |
| `starred_dashboard_widgets` | Homepage layout per project (dashboard id + widget id, position, col_span, size) |
| `widget_groups` / `widget_group_widgets` / `starred_widgets` | Legacy pre-dashboards tables, retained read-only for rollback until a follow-up drop |

#### ClickHouse vs PostgreSQL Decision Guide
- **PostgreSQL**: Relational/config data needing ACID, frequent updates, JOINs, low volume (users, organizations, projects, invitations, widgets, source maps, metric registry)
Expand All @@ -761,7 +781,7 @@ In SQLite mode (`DB_TYPE=sqlite`), the backend uses **two separate SQLite databa

**Main DB tables** (`db.DB` — transactional, uses lit with `*sql.Tx`):
- `users`, `organizations`, `organization_users`, `projects`, `invitations`
- `source_maps`, `metric_registry`, `widget_groups`, `widget_group_widgets`, `starred_widgets`
- `source_maps`, `metric_registry`, `dashboards`, `project_dashboards`, `dashboard_templates`, `starred_dashboard_widgets` (plus the legacy `widget_groups`/`widget_group_widgets`/`starred_widgets`)
- `notification_channels`, `notification_rules`

**Telemetry DB tables** (`db.TelemetryDB` — non-transactional, uses lit with `db.TelemetryDB` directly):
Expand Down
263 changes: 263 additions & 0 deletions backend/app/backfill/dashboards.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
package backfill

import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"

"github.com/tracewayapp/lit/v2"
"github.com/tracewayapp/traceway/backend/app/config"
"github.com/tracewayapp/traceway/backend/app/db"
"github.com/tracewayapp/traceway/backend/app/models"
"github.com/tracewayapp/traceway/backend/app/repositories/transactional"
dashboardsvc "github.com/tracewayapp/traceway/backend/app/services/dashboards"

"github.com/google/uuid"
)

const dashboardsBackfillName = "dashboards"

const maxDashboardNameRunes = 100

type widgetGroupWithOrg struct {
Id int `lit:"id"`
ProjectId uuid.UUID `lit:"project_id"`
Name string `lit:"name"`
Description string `lit:"description"`
IsDefault bool `lit:"is_default"`
CreatedBy *int `lit:"created_by"`
CreatedAt time.Time `lit:"created_at"`
UpdatedAt time.Time `lit:"updated_at"`
OrganizationId int `lit:"organization_id"`
ProjectName string `lit:"project_name"`
}

func init() {
models.ExtensionModelRegistrations = append(models.ExtensionModelRegistrations, func(driver lit.Driver) {
lit.RegisterModel[widgetGroupWithOrg](driver)
})
}

func RunDashboards() error {
converted, err := db.ExecuteTransaction(func(tx *sql.Tx) (int, error) {
if !db.IsSQLite() {
if _, err := tx.Exec("SELECT pg_advisory_xact_lock(824737001)"); err != nil {
return 0, fmt.Errorf("failed to take backfill lock: %w", err)
}
}

marker, err := lit.SelectSingleNamed[models.CountResult](
tx,
"SELECT COUNT(*) as count FROM backfill_runs WHERE name = :name",
lit.P{"name": dashboardsBackfillName},
)
if err != nil {
return 0, fmt.Errorf("failed to check backfill marker: %w", err)
}
if marker != nil && marker.Count > 0 {
return 0, nil
}

existing, err := lit.SelectSingleNamed[models.CountResult](tx, "SELECT COUNT(*) as count FROM dashboards", lit.P{})
if err != nil {
return 0, fmt.Errorf("failed to count dashboards: %w", err)
}

converted := 0
if existing == nil || existing.Count == 0 {
converted, err = convertWidgetGroups(tx)
if err != nil {
return 0, err
}
}

query, args, err := lit.ParseNamedQuery(
db.Driver,
"INSERT INTO backfill_runs (name, ran_at) VALUES (:name, :ran_at)",
lit.P{"name": dashboardsBackfillName, "ran_at": time.Now().UTC()},
)
if err != nil {
return 0, err
}
if err := lit.UpdateNative(tx, query, args...); err != nil {
return 0, fmt.Errorf("failed to record backfill marker: %w", err)
}

return converted, nil
})
if err != nil {
return err
}
if converted > 0 {
config.Logf("dashboards backfill: converted %d widget group(s) into dashboards", converted)
}
return nil
}

func convertWidgetGroups(tx *sql.Tx) (int, error) {
skipped, err := lit.SelectSingleNamed[models.CountResult](
tx,
"SELECT COUNT(*) as count FROM widget_groups wg JOIN projects p ON p.id = wg.project_id WHERE p.organization_id IS NULL",
lit.P{},
)
if err != nil {
return 0, fmt.Errorf("failed to count orphaned widget groups: %w", err)
}
if skipped != nil && skipped.Count > 0 {
config.Logf("dashboards backfill: skipping %d widget group(s) whose project has no organization; the widget_groups table keeps their data", skipped.Count)
}

groups, err := lit.SelectNamed[widgetGroupWithOrg](
tx,
`SELECT wg.id, wg.project_id, wg.name, COALESCE(wg.description, '') AS description, wg.is_default, wg.created_by, wg.created_at, wg.updated_at, p.organization_id, p.name AS project_name
FROM widget_groups wg
JOIN projects p ON p.id = wg.project_id
WHERE p.organization_id IS NOT NULL
ORDER BY wg.project_id ASC, wg.is_default DESC, wg.name ASC`,
lit.P{},
)
if err != nil {
return 0, fmt.Errorf("failed to list widget groups: %w", err)
}
if len(groups) == 0 {
return 0, nil
}

positions := map[uuid.UUID]int{}
usedNames := map[int]map[string]bool{}
for _, group := range groups {
widgets, err := lit.SelectNamed[models.WidgetGroupWidget](
tx,
"SELECT id, widget_group_id, title, widget_type, config, position, created_at, updated_at FROM widget_group_widgets WHERE widget_group_id = :wg_id ORDER BY position ASC",
lit.P{"wg_id": group.Id},
)
if err != nil {
return 0, fmt.Errorf("failed to list widgets for group %d: %w", group.Id, err)
}

def := &models.DashboardDefinition{SchemaVersion: dashboardsvc.SchemaVersion}
widgetIds := make(map[int]string, len(widgets))
usedIds := make(map[string]bool, len(widgets))
for _, w := range widgets {
uid := dashboardsvc.NewWidgetId()
for usedIds[uid] {
uid = dashboardsvc.NewWidgetId()
}
usedIds[uid] = true
widgetIds[w.Id] = uid
widgetConfig := json.RawMessage(w.Config)
if len(widgetConfig) == 0 {
widgetConfig = json.RawMessage(`{}`)
}
def.Widgets = append(def.Widgets, models.DashboardWidget{
Id: uid,
Title: w.Title,
WidgetType: w.WidgetType,
Config: widgetConfig,
})
}
definition, err := dashboardsvc.MarshalDefinition(def)
if err != nil {
return 0, fmt.Errorf("failed to marshal definition for group %d: %w", group.Id, err)
}

orgNames := usedNames[group.OrganizationId]
if orgNames == nil {
orgNames = map[string]bool{}
usedNames[group.OrganizationId] = orgNames
}
name := uniqueDashboardName(orgNames, group.Name, group.ProjectName)
orgNames[strings.ToLower(name)] = true

dashboard := &models.Dashboard{
OrganizationId: group.OrganizationId,
Name: name,
Description: group.Description,
Definition: definition,
CreatedBy: group.CreatedBy,
CreatedAt: group.CreatedAt,
UpdatedAt: group.UpdatedAt,
}
dashboardId, err := transactional.DashboardRepository.Create(tx, dashboard)
if err != nil {
return 0, fmt.Errorf("failed to create dashboard for group %d: %w", group.Id, err)
}

position := positions[group.ProjectId]
positions[group.ProjectId] = position + 1
if err := transactional.DashboardRepository.CreateAssignment(tx, &models.ProjectDashboard{
ProjectId: group.ProjectId,
DashboardId: dashboardId,
Position: position,
CreatedAt: group.CreatedAt,
}); err != nil {
return 0, fmt.Errorf("failed to assign dashboard for group %d: %w", group.Id, err)
}

starred, err := lit.SelectNamed[models.StarredWidget](
tx,
`SELECT sw.id, sw.widget_id, sw.position, sw.col_span, sw.size, sw.created_at
FROM starred_widgets sw
JOIN widget_group_widgets wgw ON wgw.id = sw.widget_id
WHERE wgw.widget_group_id = :wg_id
ORDER BY sw.position ASC, sw.id ASC`,
lit.P{"wg_id": group.Id},
)
if err != nil {
return 0, fmt.Errorf("failed to list starred widgets for group %d: %w", group.Id, err)
}
for _, s := range starred {
uid := widgetIds[s.WidgetId]
if uid == "" {
continue
}
if err := transactional.DashboardRepository.CreateStarred(tx, &models.StarredDashboardWidget{
ProjectId: group.ProjectId,
DashboardId: dashboardId,
WidgetId: uid,
Position: s.Position,
ColSpan: s.ColSpan,
Size: s.Size,
CreatedAt: s.CreatedAt,
}); err != nil {
return 0, fmt.Errorf("failed to migrate starred widget %d for group %d: %w", s.Id, group.Id, err)
}
}
}

return len(groups), nil
}

func uniqueDashboardName(used map[string]bool, groupName string, projectName string) string {
base := dashboardsvc.TruncateName(groupName, maxDashboardNameRunes)
if !used[strings.ToLower(base)] {
return base
}
candidate := suffixDashboardName(base, projectName, 1)
if !used[strings.ToLower(candidate)] {
return candidate
}
for n := 2; ; n++ {
candidate = suffixDashboardName(base, projectName, n)
if !used[strings.ToLower(candidate)] {
return candidate
}
}
}

func suffixDashboardName(base string, projectName string, n int) string {
numeral := ""
if n > 1 {
numeral = fmt.Sprintf(" %d", n)
}
maxProjectRunes := maxDashboardNameRunes - 4 - utf8.RuneCountInString(numeral)
suffix := " (" + dashboardsvc.TruncateName(projectName, maxProjectRunes) + ")" + numeral
limit := maxDashboardNameRunes - utf8.RuneCountInString(suffix)
if limit < 1 {
limit = 1
}
return dashboardsvc.TruncateName(base, limit) + suffix
}
Loading
Loading