Files
galaxy-game/gateway/internal/grpcapi/observability.go
T
Ilia Denisov 8565942392
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 2m22s
Tests · UI / test (push) Failing after 2m42s
feat(deploy): single-origin path-based deployment + project site
Serve the whole stack behind one host: site at /, game UI at /game/,
gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the
edge Caddy). The built artifact is domain-agnostic — the UI talks to the
gateway same-origin via relative URLs, so the same bundle runs under any
host with no rebuild and with CORS disabled.

- Rename the Connect proto service galaxy.gateway.v1.EdgeGateway ->
  edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway.
- Move the game UI under base path /game (env BASE_PATH); make the
  manifest, service-worker scope, WASM loader, and all navigation
  base-aware via a withBase helper.
- Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip.
- Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS
  allow-lists (same-origin); single host.
- New VitePress project site (site/): i18n en/ru with switcher, LaTeX
  math, minimal monospace theme; built and served at /.
- dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new
  site-build) build and seed the site; probes hit /, /game/, /healthz.
- Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy &
  local-dev READMEs, CLAUDE.md, ui/PLAN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:19:07 +02:00

110 lines
3.8 KiB
Go

package grpcapi
import (
"context"
"errors"
"path"
"time"
"galaxy/gateway/internal/logging"
"galaxy/gateway/internal/telemetry"
edgev1 "galaxy/gateway/proto/edge/v1"
"go.opentelemetry.io/otel/attribute"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// recordEdgeRequest emits the structured log entry and the
// `gateway.authenticated_grpc.*` metric pair for one authenticated edge
// request or stream outcome. The transport parameter labels the wire
// protocol the request travelled over (`connect`, `grpc`, or `grpc-web`),
// preserving stable observability semantics across the unified Connect-go
// listener.
func recordEdgeRequest(logger *zap.Logger, metrics *telemetry.Runtime, ctx context.Context, transport string, fullMethod string, req any, resp any, err error, duration time.Duration, streamKind string) {
rpcMethod := path.Base(fullMethod)
messageType, requestID, traceID := envelopeFieldsFromRequest(req)
resultCode := resultCodeFromResponse(resp)
grpcCode, grpcMessage, outcome := outcomeFromError(err)
rejectReason := telemetry.RejectReason(outcome)
attrs := []attribute.KeyValue{
attribute.String("rpc_method", rpcMethod),
attribute.String("message_type", messageType),
attribute.String("edge_outcome", string(outcome)),
}
if resultCode != "" {
attrs = append(attrs, attribute.String("result_code", resultCode))
}
if rejectReason != "" {
attrs = append(attrs, attribute.String("reject_reason", rejectReason))
}
metrics.RecordAuthenticatedGRPC(ctx, attrs, duration)
fields := []zap.Field{
zap.String("component", "authenticated_grpc"),
zap.String("transport", transport),
zap.String("stream_kind", streamKind),
zap.String("rpc_method", rpcMethod),
zap.String("message_type", messageType),
zap.String("grpc_code", grpcCode.String()),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
zap.String("request_id", requestID),
zap.String("trace_id", traceID),
zap.String("peer_ip", peerIPFromContext(ctx)),
zap.String("edge_outcome", string(outcome)),
}
if resultCode != "" {
fields = append(fields, zap.String("result_code", resultCode))
}
if rejectReason != "" {
fields = append(fields, zap.String("reject_reason", rejectReason))
}
if grpcMessage != "" {
fields = append(fields, zap.String("grpc_message", grpcMessage))
}
fields = append(fields, logging.TraceFieldsFromContext(ctx)...)
switch outcome {
case telemetry.EdgeOutcomeSuccess:
logger.Info("authenticated edge request completed", fields...)
case telemetry.EdgeOutcomeBackendUnavailable, telemetry.EdgeOutcomeDownstreamUnavailable, telemetry.EdgeOutcomeInternalError:
logger.Error("authenticated edge request failed", fields...)
default:
logger.Warn("authenticated edge request rejected", fields...)
}
}
func envelopeFieldsFromRequest(req any) (messageType string, requestID string, traceID string) {
switch typed := req.(type) {
case *edgev1.ExecuteCommandRequest:
return typed.GetMessageType(), typed.GetRequestId(), typed.GetTraceId()
case *edgev1.SubscribeEventsRequest:
return typed.GetMessageType(), typed.GetRequestId(), typed.GetTraceId()
default:
return "", "", ""
}
}
func resultCodeFromResponse(resp any) string {
typed, ok := resp.(*edgev1.ExecuteCommandResponse)
if !ok {
return ""
}
return typed.GetResultCode()
}
func outcomeFromError(err error) (codes.Code, string, telemetry.EdgeOutcome) {
switch {
case err == nil:
return codes.OK, "", telemetry.EdgeOutcomeSuccess
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return codes.Canceled, err.Error(), telemetry.EdgeOutcomeSuccess
default:
grpcStatus := status.Convert(err)
return grpcStatus.Code(), grpcStatus.Message(), telemetry.OutcomeFromGRPCStatus(grpcStatus.Code(), grpcStatus.Message())
}
}