package userbilling import ( "bytes" "context" crand "crypto/rand" "encoding/hex" "encoding/json" "errors" "io" "net/http" "os" "strconv" "strings" "time" ) const ( otelTraceIDZero = "00000000000000000000000000000000" otelSpanIDZero = "0000000000000000" otelSpanKindInternal = 1 otelSpanKindServer = 2 otelExportTimeout = 1500 * time.Millisecond ) type otelTraceContext struct { TraceID string ParentSpanID string ServerSpanID string } func newOtelTraceContext(traceparent string) otelTraceContext { traceID, parentSpanID, ok := parseOtelTraceparent(traceparent) if !ok { traceID = newOtelTraceID() parentSpanID = "" } return otelTraceContext{TraceID: traceID, ParentSpanID: parentSpanID, ServerSpanID: newOtelSpanID()} } func (ctx otelTraceContext) traceparent() string { if !validOtelHex(ctx.TraceID, 32, otelTraceIDZero) || !validOtelHex(ctx.ServerSpanID, 16, otelSpanIDZero) { return "" } return "00-" + ctx.TraceID + "-" + ctx.ServerSpanID + "-01" } func newOtelTraceID() string { return randomOtelHex(16, otelTraceIDZero) } func newOtelSpanID() string { return randomOtelHex(8, otelSpanIDZero) } func randomOtelHex(byteCount int, zero string) string { buf := make([]byte, byteCount) if _, err := crand.Read(buf); err != nil { return strings.TrimSuffix(zero, "0") + "1" } value := hex.EncodeToString(buf) if value == zero { return strings.TrimSuffix(zero, "0") + "1" } return value } func parseOtelTraceparent(value string) (string, string, bool) { parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "-") if len(parts) != 4 { return "", "", false } traceID := parts[1] spanID := parts[2] if !validOtelHex(traceID, 32, otelTraceIDZero) || !validOtelHex(spanID, 16, otelSpanIDZero) { return "", "", false } return traceID, spanID, true } func validOtelHex(value string, length int, zero string) bool { if len(value) != length || value == zero { return false } for _, ch := range value { if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') { return false } } return true } func (s *Server) emitOtelSpanAsync(name string, trace otelTraceContext, spanID string, parentSpanID string, kind int, startedAt time.Time, attrs map[string]any, httpStatus int, errorCode string) { go s.emitOtelSpan(context.Background(), name, trace, spanID, parentSpanID, kind, startedAt, attrs, httpStatus, errorCode) } func (s *Server) emitOtelSpan(ctx context.Context, name string, trace otelTraceContext, spanID string, parentSpanID string, kind int, startedAt time.Time, attrs map[string]any, httpStatus int, errorCode string) { endpoint := resolveOtelTracesEndpoint() if endpoint == "" || !validOtelHex(trace.TraceID, 32, otelTraceIDZero) || !validOtelHex(spanID, 16, otelSpanIDZero) { return } endedAt := time.Now().UTC() if startedAt.IsZero() { startedAt = endedAt } attributes := map[string]any{ "otel.trace_id": trace.TraceID, } for key, value := range attrs { if value != nil { attributes[key] = value } } if errorCode != "" { attributes["error.code"] = errorCode } statusCode := 1 status := map[string]any{"code": statusCode} if errorCode != "" || httpStatus >= 500 { statusCode = 2 status = map[string]any{"code": statusCode, "message": first(errorCode, "request_failed")} } span := map[string]any{ "traceId": trace.TraceID, "spanId": spanID, "name": name, "kind": kind, "startTimeUnixNano": strconv.FormatInt(startedAt.UTC().UnixNano(), 10), "endTimeUnixNano": strconv.FormatInt(endedAt.UnixNano(), 10), "attributes": otelAttributes(attributes), "status": status, } if validOtelHex(parentSpanID, 16, otelSpanIDZero) { span["parentSpanId"] = parentSpanID } body := map[string]any{ "resourceSpans": []any{map[string]any{ "resource": map[string]any{"attributes": otelAttributes(s.otelResourceAttributes())}, "scopeSpans": []any{map[string]any{ "scope": map[string]any{"name": "hwlab.user-billing", "version": "1"}, "spans": []any{span}, }}, }}, } payload, err := json.Marshal(body) if err != nil { return } requestCtx, cancel := context.WithTimeout(ctx, otelExportTimeout) defer cancel() request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { return } request.Header.Set("Content-Type", "application/json") client := http.Client{Timeout: otelExportTimeout} response, err := client.Do(request) if err != nil { return } _, _ = io.Copy(io.Discard, response.Body) _ = response.Body.Close() } func resolveOtelTracesEndpoint() string { if endpoint := first(os.Getenv("HWLAB_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"), os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")); endpoint != "" { return strings.TrimRight(endpoint, "/") } if endpoint := first(os.Getenv("HWLAB_OTEL_EXPORTER_OTLP_ENDPOINT"), os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")); endpoint != "" { return strings.TrimRight(endpoint, "/") + "/v1/traces" } return "" } func (s *Server) otelResourceAttributes() map[string]any { return map[string]any{ "service.name": first(os.Getenv("OTEL_SERVICE_NAME"), "hwlab-user-billing"), "deployment.environment": first(os.Getenv("HWLAB_ENVIRONMENT"), os.Getenv("HWLAB_RUNTIME_LANE"), "unknown"), "hwlab.lane": first(os.Getenv("HWLAB_RUNTIME_LANE"), os.Getenv("HWLAB_GITOPS_PROFILE"), "unknown"), "k8s.namespace.name": first(os.Getenv("POD_NAMESPACE"), os.Getenv("HWLAB_NAMESPACE"), s.config.RuntimeNamespace, "unknown"), "git.commit": first(os.Getenv("HWLAB_COMMIT_ID"), os.Getenv("HWLAB_GITOPS_SOURCE_COMMIT"), os.Getenv("HWLAB_REVISION"), "unknown"), } } func otelAttributes(values map[string]any) []map[string]any { attrs := make([]map[string]any, 0, len(values)) for key, value := range values { if key == "" || value == nil { continue } attrs = append(attrs, map[string]any{"key": key, "value": otelAnyValue(value)}) } return attrs } func otelAnyValue(value any) map[string]any { switch typed := value.(type) { case bool: return map[string]any{"boolValue": typed} case int: return map[string]any{"intValue": strconv.FormatInt(int64(typed), 10)} case int64: return map[string]any{"intValue": strconv.FormatInt(typed, 10)} case float64: return map[string]any{"doubleValue": typed} case string: return map[string]any{"stringValue": typed} default: return map[string]any{"stringValue": first(toJSONString(typed), "{}")} } } func toJSONString(value any) string { payload, err := json.Marshal(value) if err != nil { return "" } return string(payload) } type sqlStateError interface { SQLState() string } func sqlErrorCode(err error) string { if err == nil { return "" } var stateErr sqlStateError if errors.As(err, &stateErr) { if code := strings.ToUpper(strings.TrimSpace(stateErr.SQLState())); validSQLState(code) { return code } } const marker = "SQLSTATE " message := strings.ToUpper(err.Error()) index := strings.Index(message, marker) if index < 0 || len(message) < index+len(marker)+5 { return "" } code := strings.Trim(message[index+len(marker):index+len(marker)+5], "()[]{} ") if validSQLState(code) { return code } return "" } func validSQLState(code string) bool { if len(code) != 5 { return false } for _, ch := range code { if (ch < '0' || ch > '9') && (ch < 'A' || ch > 'Z') { return false } } return true }