Some checks failed
Veza CI / Notify on failure (push) Blocked by required conditions
Security Scan / Secret Scanning (gitleaks) (push) Waiting to run
Veza CI / Backend (Go) (push) Has been cancelled
Veza CI / Rust (Stream Server) (push) Has been cancelled
Veza CI / Frontend (Web) (push) Has been cancelled
E2E Playwright / e2e (full) (push) Has been cancelled
Wires distributed tracing end-to-end. Backend exports OTLP/gRPC to a
collector, which tail-samples (errors + slow always, 10% rest) and
ships to Tempo. Grafana service-map dashboard pivots on the 4
instrumented hot paths.
- internal/tracing/otlp_exporter.go : InitOTLPTracer + Provider.Shutdown,
BatchSpanProcessor (5s/512 batch), ParentBased(TraceIDRatio) sampler,
W3C trace-context + baggage propagators. OTEL_SDK_DISABLED=true
short-circuits to a no-op. Failure to dial collector is non-fatal.
- cmd/api/main.go : init at boot, defer Shutdown(5s) on exit. appVersion
ldflag-overridable for resource attributes.
- 4 hot paths instrumented :
* handlers/auth.go::Login → "auth.login"
* core/track/track_upload_handler.go::InitiateChunkedUpload → "track.upload.initiate"
* core/marketplace/service.go::ProcessPaymentWebhook → "payment.webhook"
* handlers/search_handlers.go::Search → "search.query"
PII guarded — email masked, query content not recorded (length only).
- infra/ansible/roles/otel_collector : pin v0.116.1 contrib build,
systemd unit, tail-sampling config (errors + > 500ms always kept).
- infra/ansible/roles/tempo : pin v2.7.1 monolithic, local-disk backend
(S3 deferred to v1.1), 14d retention.
- infra/ansible/playbooks/observability.yml : provisions both Incus
containers + applies common baseline + roles in order.
- inventory/lab.yml : new groups observability, otel_collectors, tempo.
- config/grafana/dashboards/service-map.json : node graph + 4 hot-path
span tables + collector throughput/queue panels.
- docs/ENV_VARIABLES.md §30 : 4 OTEL_* env vars documented.
Acceptance criterion (Day 9) : login → span visible in Tempo UI. Lab
deployment to validate with `ansible-playbook -i inventory/lab.yml
playbooks/observability.yml` once roles/postgres_ha is up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
4.4 KiB
Go
122 lines
4.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"veza-backend-api/internal/services"
|
|
"veza-backend-api/internal/tracing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/codes"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
var SearchHandlersInstance *SearchHandlers
|
|
|
|
// SearchServiceInterface defines the interface for search operations
|
|
// This allows for easier testing with mocks
|
|
type SearchServiceInterface interface {
|
|
Search(query string, types []string) (*services.SearchResult, error)
|
|
Suggestions(query string, limit int) (*services.SearchResult, error)
|
|
}
|
|
|
|
type SearchHandlers struct {
|
|
searchService SearchServiceInterface
|
|
}
|
|
|
|
func NewSearchHandlers(searchService *services.SearchService) {
|
|
SearchHandlersInstance = &SearchHandlers{
|
|
searchService: searchService,
|
|
}
|
|
}
|
|
|
|
// NewSearchHandlersWithInterface creates new search handlers with an interface (for testing)
|
|
func NewSearchHandlersWithInterface(searchService SearchServiceInterface) *SearchHandlers {
|
|
SearchHandlersInstance = &SearchHandlers{
|
|
searchService: searchService,
|
|
}
|
|
return SearchHandlersInstance
|
|
}
|
|
|
|
// Search performs a full-text search across tracks, users, and playlists
|
|
// @Summary Unified search
|
|
// @Description Postgres FTS-backed search across tracks, users, and playlists. Optional `type` filter accepts repeated values (e.g., ?type=track&type=user). v1.0.9 item 1.6 — annotation added so orval can generate a typed client.
|
|
// @Tags Search
|
|
// @Produce json
|
|
// @Param q query string true "Search query"
|
|
// @Param type query []string false "Restrict to one or more entity types: track, user, playlist" collectionFormat(multi)
|
|
// @Param cursor query string false "Opaque pagination cursor"
|
|
// @Param limit query int false "Page size (max 50)"
|
|
// @Success 200 {object} handlers.APIResponse "Search results"
|
|
// @Failure 400 {object} handlers.APIResponse "Validation error"
|
|
// @Failure 500 {object} handlers.APIResponse "Search failed"
|
|
// @Router /search [get]
|
|
func (sh *SearchHandlers) Search(c *gin.Context) {
|
|
query := c.Query("q")
|
|
if query == "" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Search query is required"))
|
|
return
|
|
}
|
|
|
|
types := c.QueryArray("type")
|
|
|
|
// v1.0.9 Day 9 — search.query span. Hot path: every search bar press
|
|
// hits this. Query content is NOT recorded (PII / search history is
|
|
// sensitive); only length + types so cardinality stays bounded.
|
|
_, span := otel.Tracer(tracing.TracerName).Start(c.Request.Context(), "search.query",
|
|
trace.WithAttributes(
|
|
attribute.Int("search.query_length", len(query)),
|
|
attribute.StringSlice("search.types", types),
|
|
),
|
|
)
|
|
defer span.End()
|
|
|
|
results, err := sh.searchService.Search(query, types)
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
span.SetStatus(codes.Error, "search failed")
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Search failed", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, results)
|
|
}
|
|
|
|
// Suggestions returns autocomplete suggestions for the search input
|
|
// @Summary Search suggestions
|
|
// @Description Lightweight autocomplete used by the search bar. Returns a small SearchResult subset (typically tracks + users + playlists), capped at `limit` (1..20, default 5). v1.0.9 item 1.6 — annotation added so orval can generate a typed client.
|
|
// @Tags Search
|
|
// @Produce json
|
|
// @Param q query string true "Partial query (min 1 char)"
|
|
// @Param limit query int false "Max number of suggestions (1..20, default 5)"
|
|
// @Success 200 {object} handlers.APIResponse "Suggestions"
|
|
// @Failure 400 {object} handlers.APIResponse "Validation error"
|
|
// @Failure 500 {object} handlers.APIResponse "Suggestions failed"
|
|
// @Router /search/suggestions [get]
|
|
func (sh *SearchHandlers) Suggestions(c *gin.Context) {
|
|
query := c.Query("q")
|
|
if query == "" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Query parameter 'q' is required"))
|
|
return
|
|
}
|
|
limit := 5
|
|
if l := c.Query("limit"); l != "" {
|
|
if n, err := parseInt(l); err == nil && n > 0 && n <= 20 {
|
|
limit = n
|
|
}
|
|
}
|
|
results, err := sh.searchService.Suggestions(query, limit)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Suggestions failed", err))
|
|
return
|
|
}
|
|
RespondSuccess(c, http.StatusOK, results)
|
|
}
|
|
|
|
func parseInt(s string) (int, error) {
|
|
return strconv.Atoi(s)
|
|
}
|