Files
honeyDueAPI/internal/push/fcm.go
T
Trey t bc3da007db
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Wire OpenTelemetry tracing — HTTP, B2, APNs, FCM, asynq, GORM (partial)
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider
that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling
is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod,
overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api
and honeydue-worker. otelecho.Middleware opens a span per HTTP request.

Step 2 — Manual spans: storage_service.Upload now takes ctx and emits
storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket,
result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token
spans with topic, status_code, reason. Asynq middleware emits
asynq.handle:<task_type> per job with retry/payload attrs and records
asynq_job_duration_seconds.

Step 3 — Database: otelgorm plugin registered in database.Connect, so
any SQL emitted via db.WithContext(ctx) attaches to the request span.
Every repository now exposes WithContext(ctx) *XRepository as the
migration helper. TaskService.ListTasks and GetTasksByResidence are
migrated end-to-end (ctx threaded through handler → service → repo);
remaining services adopt the same pattern incrementally — pre-migration
methods still emit untraced SQL via the unchanged db field.

OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env →
honeydue-secrets → api+worker Deployments via secretKeyRef (optional).
02-setup-secrets.sh sources them from prod.env on next run; manifests
mark both env vars optional so the deployment rolls without traces if
the secret is absent.

ch15 observability doc now lists what produces spans today vs the
remaining migration work, with the explicit per-method pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:28:05 -05:00

337 lines
9.7 KiB
Go

package push
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2/google"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/prom"
"github.com/treytartt/honeydue-api/internal/tracing"
)
var fcmTracer = tracing.Tracer("honeydue/push/fcm")
const (
// fcmV1EndpointFmt is the FCM HTTP v1 API endpoint template.
fcmV1EndpointFmt = "https://fcm.googleapis.com/v1/projects/%s/messages:send"
// fcmScope is the OAuth 2.0 scope required for FCM HTTP v1 API.
fcmScope = "https://www.googleapis.com/auth/firebase.messaging"
)
// FCMClient handles communication with Firebase Cloud Messaging
// using the HTTP v1 API and OAuth 2.0 service account authentication.
type FCMClient struct {
projectID string
endpoint string
httpClient *http.Client
}
// --- Request types (FCM v1 API) ---
// fcmV1Request is the top-level request body for the FCM v1 API.
type fcmV1Request struct {
Message *fcmV1Message `json:"message"`
}
// fcmV1Message represents a single FCM v1 message.
type fcmV1Message struct {
Token string `json:"token"`
Notification *FCMNotification `json:"notification,omitempty"`
Data map[string]string `json:"data,omitempty"`
Android *fcmAndroidConfig `json:"android,omitempty"`
}
// FCMNotification represents the notification payload.
type FCMNotification struct {
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}
// fcmAndroidConfig provides Android-specific message configuration.
type fcmAndroidConfig struct {
Priority string `json:"priority,omitempty"`
}
// --- Response types (FCM v1 API) ---
// fcmV1Response is the successful response from the FCM v1 API.
type fcmV1Response struct {
Name string `json:"name"` // e.g. "projects/myproject/messages/0:1234567890"
}
// fcmV1ErrorResponse is the error response from the FCM v1 API.
type fcmV1ErrorResponse struct {
Error fcmV1Error `json:"error"`
}
// fcmV1Error contains the structured error details.
type fcmV1Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
Details json.RawMessage `json:"details,omitempty"`
}
// --- Error types ---
// FCMErrorCode represents well-known FCM v1 error codes for programmatic handling.
type FCMErrorCode string
const (
FCMErrUnregistered FCMErrorCode = "UNREGISTERED"
FCMErrQuotaExceeded FCMErrorCode = "QUOTA_EXCEEDED"
FCMErrUnavailable FCMErrorCode = "UNAVAILABLE"
FCMErrInternal FCMErrorCode = "INTERNAL"
FCMErrInvalidArgument FCMErrorCode = "INVALID_ARGUMENT"
FCMErrSenderIDMismatch FCMErrorCode = "SENDER_ID_MISMATCH"
FCMErrThirdPartyAuth FCMErrorCode = "THIRD_PARTY_AUTH_ERROR"
)
// FCMSendError is a structured error returned when an individual FCM send fails.
type FCMSendError struct {
Token string
StatusCode int
ErrorCode FCMErrorCode
Message string
}
func (e *FCMSendError) Error() string {
return fmt.Sprintf("FCM send failed for token %s: %s (status %d, code %s)",
truncateToken(e.Token), e.Message, e.StatusCode, e.ErrorCode)
}
// IsUnregistered returns true if the device token is no longer valid and should be removed.
func (e *FCMSendError) IsUnregistered() bool {
return e.ErrorCode == FCMErrUnregistered
}
// --- OAuth 2.0 transport ---
// oauth2BearerTransport is an http.RoundTripper that attaches an OAuth 2.0 Bearer
// token to every outgoing request. The token source handles refresh automatically.
type oauth2BearerTransport struct {
base http.RoundTripper
getToken func() (string, error)
}
func (t *oauth2BearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
accessToken, err := t.getToken()
if err != nil {
return nil, fmt.Errorf("failed to obtain OAuth 2.0 token for FCM: %w", err)
}
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+accessToken)
return t.base.RoundTrip(r)
}
// --- Client construction ---
// NewFCMClient creates a new FCM client using the HTTP v1 API with OAuth 2.0
// service account authentication. It accepts either a path to a service account
// JSON file or the raw JSON content directly via config.
func NewFCMClient(cfg *config.PushConfig) (*FCMClient, error) {
if cfg.FCMProjectID == "" {
return nil, fmt.Errorf("FCM project ID not configured (set FCM_PROJECT_ID)")
}
credJSON, err := resolveServiceAccountJSON(cfg)
if err != nil {
return nil, err
}
// Create OAuth 2.0 credentials with the FCM messaging scope.
// The google library handles automatic token refresh.
creds, err := google.CredentialsFromJSON(context.Background(), credJSON, fcmScope)
if err != nil {
return nil, fmt.Errorf("failed to parse FCM service account credentials: %w", err)
}
// Build an HTTP client that automatically attaches and refreshes OAuth tokens.
transport := &oauth2BearerTransport{
base: http.DefaultTransport,
getToken: func() (string, error) {
tok, err := creds.TokenSource.Token()
if err != nil {
return "", err
}
return tok.AccessToken, nil
},
}
httpClient := &http.Client{
Timeout: 15 * time.Second,
Transport: transport,
}
endpoint := fmt.Sprintf(fcmV1EndpointFmt, cfg.FCMProjectID)
log.Info().
Str("project_id", cfg.FCMProjectID).
Str("endpoint", endpoint).
Msg("FCM v1 client initialized with OAuth 2.0")
return &FCMClient{
projectID: cfg.FCMProjectID,
endpoint: endpoint,
httpClient: httpClient,
}, nil
}
// resolveServiceAccountJSON returns the service account JSON bytes from config.
func resolveServiceAccountJSON(cfg *config.PushConfig) ([]byte, error) {
if cfg.FCMServiceAccountJSON != "" {
return []byte(cfg.FCMServiceAccountJSON), nil
}
if cfg.FCMServiceAccountPath != "" {
data, err := os.ReadFile(cfg.FCMServiceAccountPath)
if err != nil {
return nil, fmt.Errorf("failed to read FCM service account file %s: %w", cfg.FCMServiceAccountPath, err)
}
return data, nil
}
return nil, fmt.Errorf("FCM service account not configured (set FCM_SERVICE_ACCOUNT_PATH or FCM_SERVICE_ACCOUNT_JSON)")
}
// --- Sending ---
// Send sends a push notification to Android devices via the FCM HTTP v1 API.
// The v1 API requires one request per device token, so this iterates over all tokens.
// The method signature is kept identical to the previous legacy implementation
// so callers do not need to change.
func (c *FCMClient) Send(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
if len(tokens) == 0 {
return nil
}
var sendErrors []error
successCount := 0
for _, tokenStr := range tokens {
sendCtx, span := fcmTracer.Start(ctx, "fcm.send",
trace.WithAttributes(
attribute.String("fcm.token", truncateToken(tokenStr)),
attribute.String("fcm.priority", "HIGH"),
),
)
sendStart := time.Now()
err := c.sendOne(sendCtx, tokenStr, title, message, data)
if err != nil {
result := "error"
var fcmErr *FCMSendError
if errors.As(err, &fcmErr) && fcmErr.IsUnregistered() {
result = "bad_token"
}
prom.ObserveFCMSend(result, time.Since(sendStart))
span.SetAttributes(attribute.String("fcm.result", result))
span.SetStatus(codes.Error, result)
span.RecordError(err)
span.End()
log.Error().
Err(err).
Str("token", truncateToken(tokenStr)).
Msg("FCM v1 notification failed")
sendErrors = append(sendErrors, err)
continue
}
prom.ObserveFCMSend("ok", time.Since(sendStart))
span.SetAttributes(attribute.String("fcm.result", "ok"))
span.End()
successCount++
log.Debug().
Str("token", truncateToken(tokenStr)).
Msg("FCM v1 notification sent successfully")
}
log.Info().
Int("total", len(tokens)).
Int("success", successCount).
Int("failed", len(sendErrors)).
Msg("FCM v1 batch send complete")
if len(sendErrors) > 0 && successCount == 0 {
return fmt.Errorf("all FCM notifications failed: first error: %w", sendErrors[0])
}
return nil
}
// sendOne sends a single FCM v1 message to one device token.
func (c *FCMClient) sendOne(ctx context.Context, token, title, message string, data map[string]string) error {
reqBody := fcmV1Request{
Message: &fcmV1Message{
Token: token,
Notification: &FCMNotification{
Title: title,
Body: message,
},
Data: data,
Android: &fcmAndroidConfig{
Priority: "HIGH",
},
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal FCM v1 message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create FCM v1 request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send FCM v1 request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read FCM v1 response body: %w", err)
}
if resp.StatusCode == http.StatusOK {
return nil
}
// Parse the error response for structured error information.
return parseFCMV1Error(token, resp.StatusCode, respBody)
}
// parseFCMV1Error extracts a structured FCMSendError from the v1 API error response.
func parseFCMV1Error(token string, statusCode int, body []byte) *FCMSendError {
var errResp fcmV1ErrorResponse
if err := json.Unmarshal(body, &errResp); err != nil {
return &FCMSendError{
Token: token,
StatusCode: statusCode,
Message: fmt.Sprintf("unparseable error response: %s", string(body)),
}
}
return &FCMSendError{
Token: token,
StatusCode: statusCode,
ErrorCode: FCMErrorCode(errResp.Error.Status),
Message: errResp.Error.Message,
}
}