259 lines
7.3 KiB
Go
259 lines
7.3 KiB
Go
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
|
|
}
|