Files
honeyDueAPI/internal/push/apns.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

260 lines
7.3 KiB
Go

package push
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog/log"
"github.com/sideshow/apns2"
"github.com/sideshow/apns2/payload"
"github.com/sideshow/apns2/token"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/prom"
"github.com/treytartt/honeydue-api/internal/tracing"
)
var apnsTracer = tracing.Tracer("honeydue/push/apns")
// APNsClient handles direct communication with Apple Push Notification service
type APNsClient struct {
client *apns2.Client
topic string
}
// NewAPNsClient creates a new APNs client using token-based authentication
func NewAPNsClient(cfg *config.PushConfig) (*APNsClient, error) {
if cfg.APNSKeyPath == "" || cfg.APNSKeyID == "" || cfg.APNSTeamID == "" {
return nil, fmt.Errorf("APNs configuration incomplete: key_path=%s, key_id=%s, team_id=%s",
cfg.APNSKeyPath, cfg.APNSKeyID, cfg.APNSTeamID)
}
// Load the APNs auth key (.p8 file)
authKey, err := token.AuthKeyFromFile(cfg.APNSKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load APNs auth key from %s: %w", cfg.APNSKeyPath, err)
}
// Create token for authentication
authToken := &token.Token{
AuthKey: authKey,
KeyID: cfg.APNSKeyID,
TeamID: cfg.APNSTeamID,
}
// Create client - sandbox if APNSSandbox is true, production otherwise.
// APNSSandbox is the single source of truth (defaults to true for safety).
var client *apns2.Client
if cfg.APNSSandbox {
client = apns2.NewTokenClient(authToken).Development()
log.Info().Msg("APNs client configured for DEVELOPMENT/SANDBOX")
} else {
client = apns2.NewTokenClient(authToken).Production()
log.Info().Msg("APNs client configured for PRODUCTION")
}
return &APNsClient{
client: client,
topic: cfg.APNSTopic,
}, nil
}
// Send sends a push notification to iOS devices
func (c *APNsClient) Send(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
if len(tokens) == 0 {
return nil
}
// Build the notification payload
p := payload.NewPayload().
AlertTitle(title).
AlertBody(message).
Sound("default").
MutableContent()
// Add custom data
for key, value := range data {
p.Custom(key, value)
}
var errors []error
successCount := 0
for _, deviceToken := range tokens {
notification := &apns2.Notification{
DeviceToken: deviceToken,
Topic: c.topic,
Payload: p,
Priority: apns2.PriorityHigh,
}
sendCtx, span := apnsTracer.Start(ctx, "apns.send",
trace.WithAttributes(
attribute.String("apns.topic", c.topic),
attribute.String("apns.token", truncateToken(deviceToken)),
attribute.String("apns.priority", "high"),
),
)
sendStart := time.Now()
res, err := c.client.PushWithContext(sendCtx, notification)
if err != nil {
prom.ObserveAPNsSend("error", time.Since(sendStart))
span.SetStatus(codes.Error, "push failed")
span.RecordError(err)
span.End()
log.Error().
Err(err).
Str("token", truncateToken(deviceToken)).
Msg("Failed to send APNs notification")
errors = append(errors, fmt.Errorf("token %s: %w", truncateToken(deviceToken), err))
continue
}
if !res.Sent() {
prom.ObserveAPNsSend("bad_token", time.Since(sendStart))
span.SetAttributes(
attribute.Int("apns.status_code", res.StatusCode),
attribute.String("apns.reason", res.Reason),
)
span.SetStatus(codes.Error, "bad token")
span.End()
log.Error().
Str("token", truncateToken(deviceToken)).
Str("reason", res.Reason).
Int("status", res.StatusCode).
Msg("APNs notification not sent")
errors = append(errors, fmt.Errorf("token %s: %s (status %d)", truncateToken(deviceToken), res.Reason, res.StatusCode))
continue
}
prom.ObserveAPNsSend("ok", time.Since(sendStart))
span.SetAttributes(attribute.String("apns.id", res.ApnsID))
span.End()
successCount++
log.Debug().
Str("token", truncateToken(deviceToken)).
Str("apns_id", res.ApnsID).
Msg("APNs notification sent successfully")
}
log.Info().
Int("total", len(tokens)).
Int("success", successCount).
Int("failed", len(errors)).
Msg("APNs batch send complete")
if len(errors) > 0 && successCount == 0 {
return fmt.Errorf("all APNs notifications failed: %v", errors)
}
return nil
}
// SendWithCategory sends a push notification with iOS category for actionable notifications
func (c *APNsClient) SendWithCategory(ctx context.Context, tokens []string, title, message string, data map[string]string, categoryID string) error {
if len(tokens) == 0 {
return nil
}
// Build the notification payload with category
p := payload.NewPayload().
AlertTitle(title).
AlertBody(message).
Sound("default").
MutableContent().
Category(categoryID) // iOS category for actionable notifications
// Add custom data
for key, value := range data {
p.Custom(key, value)
}
var errors []error
successCount := 0
for _, deviceToken := range tokens {
notification := &apns2.Notification{
DeviceToken: deviceToken,
Topic: c.topic,
Payload: p,
Priority: apns2.PriorityHigh,
}
sendCtx, span := apnsTracer.Start(ctx, "apns.send.category",
trace.WithAttributes(
attribute.String("apns.topic", c.topic),
attribute.String("apns.token", truncateToken(deviceToken)),
attribute.String("apns.category_id", categoryID),
),
)
sendStart := time.Now()
res, err := c.client.PushWithContext(sendCtx, notification)
if err != nil {
prom.ObserveAPNsSend("error", time.Since(sendStart))
span.SetStatus(codes.Error, "push failed")
span.RecordError(err)
span.End()
log.Error().
Err(err).
Str("token", truncateToken(deviceToken)).
Str("category", categoryID).
Msg("Failed to send APNs actionable notification")
errors = append(errors, fmt.Errorf("token %s: %w", truncateToken(deviceToken), err))
continue
}
if !res.Sent() {
prom.ObserveAPNsSend("bad_token", time.Since(sendStart))
span.SetAttributes(
attribute.Int("apns.status_code", res.StatusCode),
attribute.String("apns.reason", res.Reason),
)
span.SetStatus(codes.Error, "bad token")
span.End()
log.Error().
Str("token", truncateToken(deviceToken)).
Str("reason", res.Reason).
Int("status", res.StatusCode).
Str("category", categoryID).
Msg("APNs actionable notification not sent")
errors = append(errors, fmt.Errorf("token %s: %s (status %d)", truncateToken(deviceToken), res.Reason, res.StatusCode))
continue
}
prom.ObserveAPNsSend("ok", time.Since(sendStart))
span.SetAttributes(attribute.String("apns.id", res.ApnsID))
span.End()
successCount++
log.Debug().
Str("token", truncateToken(deviceToken)).
Str("apns_id", res.ApnsID).
Str("category", categoryID).
Msg("APNs actionable notification sent successfully")
}
log.Info().
Int("total", len(tokens)).
Int("success", successCount).
Int("failed", len(errors)).
Str("category", categoryID).
Msg("APNs actionable batch send complete")
if len(errors) > 0 && successCount == 0 {
return fmt.Errorf("all APNs actionable notifications failed: %v", errors)
}
return nil
}
// truncateToken returns first 8 chars of token for logging
func truncateToken(token string) string {
if len(token) > 8 {
return token[:8] + "..."
}
return token
}