Skip to content

Commit a3c5259

Browse files
committed
feat: support analytics on apple-container runtime
Enable the analytics stack when services run on the apple-container runtime. This adds an Apple-specific log ingestion path for Vector by forwarding container logs to host-side JSONL files and configuring Vector to read those files instead of Docker's log source. Docker behavior remains unchanged.
1 parent 298d70e commit a3c5259

8 files changed

Lines changed: 595 additions & 51 deletions

File tree

cmd/apple_log_forwarder.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
"github.com/supabase/cli/internal/utils"
6+
)
7+
8+
var (
9+
appleLogForwarderContainer string
10+
appleLogForwarderOutput string
11+
12+
appleLogForwarderCmd = &cobra.Command{
13+
Use: "apple-log-forwarder",
14+
Short: "Internal Apple analytics log forwarder",
15+
Hidden: true,
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
return utils.RunAppleAnalyticsLogForwarder(cmd.Context(), appleLogForwarderContainer, appleLogForwarderOutput)
18+
},
19+
}
20+
)
21+
22+
func init() {
23+
flags := appleLogForwarderCmd.Flags()
24+
flags.StringVar(&appleLogForwarderContainer, "container", "", "container id to follow")
25+
flags.StringVar(&appleLogForwarderOutput, "output", "", "output path for JSONL logs")
26+
cobra.CheckErr(appleLogForwarderCmd.MarkFlagRequired("container"))
27+
cobra.CheckErr(appleLogForwarderCmd.MarkFlagRequired("output"))
28+
rootCmd.AddCommand(appleLogForwarderCmd)
29+
}

cmd/root.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/signal"
1111
"strings"
12+
"syscall"
1213
"time"
1314

1415
"github.com/getsentry/sentry-go"
@@ -93,7 +94,7 @@ var (
9394
}
9495
cmd.SilenceUsage = true
9596
// Load profile before changing workdir
96-
ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
97+
ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
9798
fsys := afero.NewOsFs()
9899
if err := utils.LoadProfile(ctx, fsys); err != nil {
99100
return err

internal/start/start.go

Lines changed: 92 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,17 @@ type vectorConfig struct {
135135
ApiKey string
136136
VectorId string
137137
LogflareId string
138+
LogflareHost string
138139
KongId string
139140
GotrueId string
140141
RestId string
141142
RealtimeId string
142143
StorageId string
143144
EdgeRuntimeId string
144145
DbId string
146+
SourceName string
147+
SourceType string
148+
SourceInclude []string
145149
}
146150

147151
var (
@@ -169,6 +173,18 @@ var (
169173

170174
var serviceTimeout = 30 * time.Second
171175

176+
var (
177+
startAppleAnalyticsForwarders = utils.StartAppleAnalyticsForwarders
178+
stopAppleAnalyticsForwarders = utils.StopAppleAnalyticsForwarders
179+
)
180+
181+
const (
182+
vectorSourceDockerLogs = "docker_logs"
183+
vectorSourceFile = "file"
184+
appleVectorLogDir = "/var/log/supabase"
185+
appleVectorLogGlob = appleVectorLogDir + "/*.jsonl"
186+
)
187+
172188
var resolveContainerIP = utils.GetContainerIP
173189
var listProjectContainers = utils.ListProjectContainers
174190
var removeProjectContainer = utils.RemoveContainer
@@ -287,6 +303,35 @@ func buildKongConfig(ctx context.Context, deps KongDependencies) (kongConfig, er
287303
}, nil
288304
}
289305

306+
func buildVectorConfig(ctx context.Context) (vectorConfig, error) {
307+
cfg := vectorConfig{
308+
ApiKey: utils.Config.Analytics.ApiKey,
309+
VectorId: utils.VectorId,
310+
LogflareId: utils.LogflareId,
311+
LogflareHost: utils.LogflareId,
312+
KongId: utils.KongId,
313+
GotrueId: utils.GotrueId,
314+
RestId: utils.RestId,
315+
RealtimeId: utils.RealtimeId,
316+
StorageId: utils.StorageId,
317+
EdgeRuntimeId: utils.EdgeRuntimeId,
318+
DbId: utils.DbId,
319+
SourceName: "docker_host",
320+
SourceType: vectorSourceDockerLogs,
321+
}
322+
if utils.UsesAppleContainerRuntime() {
323+
logflareHost, err := runtimeContainerHost(ctx, utils.LogflareId, true)
324+
if err != nil {
325+
return vectorConfig{}, err
326+
}
327+
cfg.LogflareHost = logflareHost
328+
cfg.SourceName = "apple_logs"
329+
cfg.SourceType = vectorSourceFile
330+
cfg.SourceInclude = []string{appleVectorLogGlob}
331+
}
332+
return cfg, nil
333+
}
334+
290335
func startKong(ctx context.Context, deps KongDependencies) error {
291336
var kongConfigBuf bytes.Buffer
292337
kongConfig, err := buildKongConfig(ctx, deps)
@@ -478,17 +523,13 @@ func run(ctx context.Context, fsys afero.Fs, excludedContainers []string, dbConf
478523
for _, name := range excludedContainers {
479524
excluded[name] = true
480525
}
481-
if utils.UsesAppleContainerRuntime() {
482-
if !excluded[utils.ShortContainerImageName(utils.Config.Analytics.Image)] || !excluded[utils.ShortContainerImageName(utils.Config.Analytics.VectorImage)] {
483-
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "apple-container runtime does not support analytics yet; skipping logflare and vector.")
484-
excluded[utils.ShortContainerImageName(utils.Config.Analytics.Image)] = true
485-
excluded[utils.ShortContainerImageName(utils.Config.Analytics.VectorImage)] = true
486-
}
487-
}
488526
notExcluded := func(sc types.ServiceConfig) bool {
489527
val, ok := excluded[sc.Name]
490528
return !val || !ok
491529
}
530+
if utils.UsesAppleContainerRuntime() && !excluded[utils.ShortContainerImageName(utils.Config.Analytics.VectorImage)] {
531+
_ = stopAppleAnalyticsForwarders(afero.NewOsFs())
532+
}
492533

493534
jwks, err := utils.Config.Auth.ResolveJWKS(ctx)
494535
if err != nil {
@@ -626,49 +667,54 @@ EOF
626667

627668
// Start vector
628669
if isVectorEnabled {
670+
cfg, err := buildVectorConfig(ctx)
671+
if err != nil {
672+
return err
673+
}
629674
var vectorConfigBuf bytes.Buffer
630-
if err := vectorConfigTemplate.Option("missingkey=error").Execute(&vectorConfigBuf, vectorConfig{
631-
ApiKey: utils.Config.Analytics.ApiKey,
632-
VectorId: utils.VectorId,
633-
LogflareId: utils.LogflareId,
634-
KongId: utils.KongId,
635-
GotrueId: utils.GotrueId,
636-
RestId: utils.RestId,
637-
RealtimeId: utils.RealtimeId,
638-
StorageId: utils.StorageId,
639-
EdgeRuntimeId: utils.EdgeRuntimeId,
640-
DbId: utils.DbId,
641-
}); err != nil {
675+
if err := vectorConfigTemplate.Option("missingkey=error").Execute(&vectorConfigBuf, cfg); err != nil {
642676
return errors.Errorf("failed to exec template: %w", err)
643677
}
644678
var binds, env, securityOpts []string
679+
if utils.UsesAppleContainerRuntime() {
680+
hostLogDir, err := utils.AppleAnalyticsLogsDirPath()
681+
if err != nil {
682+
return errors.Errorf("failed to resolve apple analytics log dir: %w", err)
683+
}
684+
if err := os.MkdirAll(hostLogDir, 0755); err != nil {
685+
return errors.Errorf("failed to create apple analytics log dir: %w", err)
686+
}
687+
binds = append(binds, hostLogDir+":"+appleVectorLogDir+":rw")
688+
}
645689
// Special case for GitLab pipeline
646690
parsed, err := client.ParseHostURL(utils.Docker.DaemonHost())
647691
if err != nil {
648692
return errors.Errorf("failed to parse docker host: %w", err)
649693
}
650694
// Ref: https://vector.dev/docs/reference/configuration/sources/docker_logs/#docker_host
651-
dindHost := &url.URL{Scheme: "http", Host: net.JoinHostPort(utils.DinDHost, "2375")}
652-
switch parsed.Scheme {
653-
case "tcp":
654-
if _, port, err := net.SplitHostPort(parsed.Host); err == nil {
655-
dindHost.Host = net.JoinHostPort(utils.DinDHost, port)
656-
}
657-
env = append(env, "DOCKER_HOST="+dindHost.String())
658-
case "npipe":
659-
const dockerDaemonNeededErr = "Analytics on Windows requires Docker daemon exposed on tcp://localhost:2375.\nSee https://supabase.com/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=windows#running-supabase-locally for more details."
660-
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), dockerDaemonNeededErr)
661-
env = append(env, "DOCKER_HOST="+dindHost.String())
662-
case "unix":
663-
if dindHost, err = client.ParseHostURL(client.DefaultDockerHost); err != nil {
664-
return errors.Errorf("failed to parse default host: %w", err)
665-
} else if strings.HasSuffix(parsed.Host, "/.docker/run/docker.sock") {
666-
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "analytics requires mounting default docker socket:", dindHost.Host)
667-
binds = append(binds, fmt.Sprintf("%[1]s:%[1]s:ro", dindHost.Host))
668-
} else {
669-
// Podman and OrbStack can mount root-less socket without issue
670-
binds = append(binds, fmt.Sprintf("%s:%s:ro", parsed.Host, dindHost.Host))
671-
securityOpts = append(securityOpts, "label:disable")
695+
if !utils.UsesAppleContainerRuntime() {
696+
dindHost := &url.URL{Scheme: "http", Host: net.JoinHostPort(utils.DinDHost, "2375")}
697+
switch parsed.Scheme {
698+
case "tcp":
699+
if _, port, err := net.SplitHostPort(parsed.Host); err == nil {
700+
dindHost.Host = net.JoinHostPort(utils.DinDHost, port)
701+
}
702+
env = append(env, "DOCKER_HOST="+dindHost.String())
703+
case "npipe":
704+
const dockerDaemonNeededErr = "Analytics on Windows requires Docker daemon exposed on tcp://localhost:2375.\nSee https://supabase.com/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=windows#running-supabase-locally for more details."
705+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), dockerDaemonNeededErr)
706+
env = append(env, "DOCKER_HOST="+dindHost.String())
707+
case "unix":
708+
if dindHost, err = client.ParseHostURL(client.DefaultDockerHost); err != nil {
709+
return errors.Errorf("failed to parse default host: %w", err)
710+
} else if strings.HasSuffix(parsed.Host, "/.docker/run/docker.sock") {
711+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "analytics requires mounting default docker socket:", dindHost.Host)
712+
binds = append(binds, fmt.Sprintf("%[1]s:%[1]s:ro", dindHost.Host))
713+
} else {
714+
// Podman and OrbStack can mount root-less socket without issue
715+
binds = append(binds, fmt.Sprintf("%s:%s:ro", parsed.Host, dindHost.Host))
716+
securityOpts = append(securityOpts, "label:disable")
717+
}
672718
}
673719
}
674720
if _, err := utils.DockerStart(
@@ -1399,6 +1445,12 @@ EOF
13991445
started = append(started, utils.KongId)
14001446
}
14011447

1448+
if utils.UsesAppleContainerRuntime() && isVectorEnabled {
1449+
if err := startAppleAnalyticsForwarders(utils.AppleAnalyticsSourceContainers()); err != nil {
1450+
return err
1451+
}
1452+
}
1453+
14021454
// Start Studio.
14031455
if isStudioEnabled {
14041456
binds, _, err := serve.PopulatePerFunctionConfigs(workdir, "", nil, fsys)

internal/start/start_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,127 @@ func TestBuildStudioEnv(t *testing.T) {
497497
assert.True(t, foundFunctionsDir)
498498
}
499499

500+
func TestBuildVectorConfig(t *testing.T) {
501+
originalRuntime := utils.Config.Local.Runtime
502+
originalResolver := resolveContainerIP
503+
originalIDs := struct {
504+
vector, logflare, kong, gotrue, rest, realtime, storage, edge, db string
505+
}{
506+
vector: utils.VectorId,
507+
logflare: utils.LogflareId,
508+
kong: utils.KongId,
509+
gotrue: utils.GotrueId,
510+
rest: utils.RestId,
511+
realtime: utils.RealtimeId,
512+
storage: utils.StorageId,
513+
edge: utils.EdgeRuntimeId,
514+
db: utils.DbId,
515+
}
516+
t.Cleanup(func() {
517+
utils.Config.Local.Runtime = originalRuntime
518+
resolveContainerIP = originalResolver
519+
utils.VectorId = originalIDs.vector
520+
utils.LogflareId = originalIDs.logflare
521+
utils.KongId = originalIDs.kong
522+
utils.GotrueId = originalIDs.gotrue
523+
utils.RestId = originalIDs.rest
524+
utils.RealtimeId = originalIDs.realtime
525+
utils.StorageId = originalIDs.storage
526+
utils.EdgeRuntimeId = originalIDs.edge
527+
utils.DbId = originalIDs.db
528+
})
529+
utils.VectorId = "test-vector"
530+
utils.LogflareId = "test-logflare"
531+
utils.KongId = "test-kong"
532+
utils.GotrueId = "test-gotrue"
533+
utils.RestId = "test-rest"
534+
utils.RealtimeId = "test-realtime"
535+
utils.StorageId = "test-storage"
536+
utils.EdgeRuntimeId = "test-edge"
537+
utils.DbId = "test-db"
538+
539+
t.Run("uses docker source by default", func(t *testing.T) {
540+
utils.Config.Local.Runtime = config.DockerRuntime
541+
542+
cfg, err := buildVectorConfig(context.Background())
543+
544+
require.NoError(t, err)
545+
assert.Equal(t, vectorSourceDockerLogs, cfg.SourceType)
546+
assert.Equal(t, "docker_host", cfg.SourceName)
547+
assert.Empty(t, cfg.SourceInclude)
548+
assert.Equal(t, "test-logflare", cfg.LogflareHost)
549+
})
550+
551+
t.Run("uses file source and resolved logflare host on apple", func(t *testing.T) {
552+
utils.Config.Local.Runtime = config.AppleContainerRuntime
553+
resolveContainerIP = func(_ context.Context, containerId, _ string) (string, error) {
554+
assert.Equal(t, "test-logflare", containerId)
555+
return "192.168.0.40", nil
556+
}
557+
558+
cfg, err := buildVectorConfig(context.Background())
559+
560+
require.NoError(t, err)
561+
assert.Equal(t, vectorSourceFile, cfg.SourceType)
562+
assert.Equal(t, "apple_logs", cfg.SourceName)
563+
assert.Equal(t, []string{appleVectorLogGlob}, cfg.SourceInclude)
564+
assert.Equal(t, "192.168.0.40", cfg.LogflareHost)
565+
})
566+
}
567+
568+
func TestRenderVectorConfig(t *testing.T) {
569+
t.Run("renders docker log source", func(t *testing.T) {
570+
var buf bytes.Buffer
571+
err := vectorConfigTemplate.Option("missingkey=error").Execute(&buf, vectorConfig{
572+
ApiKey: "api-key",
573+
VectorId: "test-vector",
574+
LogflareHost: "test-logflare",
575+
KongId: "test-kong",
576+
GotrueId: "test-gotrue",
577+
RestId: "test-rest",
578+
RealtimeId: "test-realtime",
579+
StorageId: "test-storage",
580+
EdgeRuntimeId: "test-edge",
581+
DbId: "test-db",
582+
SourceName: "docker_host",
583+
SourceType: vectorSourceDockerLogs,
584+
})
585+
require.NoError(t, err)
586+
rendered := buf.String()
587+
assert.Contains(t, rendered, "docker_host:")
588+
assert.Contains(t, rendered, "type: docker_logs")
589+
assert.Contains(t, rendered, "exclude_containers:")
590+
assert.Contains(t, rendered, "http://test-logflare:4000/api/logs?source_name=gotrue.logs.prod")
591+
})
592+
593+
t.Run("renders apple file source", func(t *testing.T) {
594+
var buf bytes.Buffer
595+
err := vectorConfigTemplate.Option("missingkey=error").Execute(&buf, vectorConfig{
596+
ApiKey: "api-key",
597+
VectorId: "test-vector",
598+
LogflareHost: "192.168.0.40",
599+
KongId: "test-kong",
600+
GotrueId: "test-gotrue",
601+
RestId: "test-rest",
602+
RealtimeId: "test-realtime",
603+
StorageId: "test-storage",
604+
EdgeRuntimeId: "test-edge",
605+
DbId: "test-db",
606+
SourceName: "apple_logs",
607+
SourceType: vectorSourceFile,
608+
SourceInclude: []string{appleVectorLogGlob},
609+
})
610+
require.NoError(t, err)
611+
rendered := buf.String()
612+
assert.Contains(t, rendered, "apple_logs:")
613+
assert.Contains(t, rendered, "type: file")
614+
assert.Contains(t, rendered, appleVectorLogGlob)
615+
assert.Contains(t, rendered, "apple_json_logs:")
616+
assert.Contains(t, rendered, `. = parse_json!(string!(.message))`)
617+
assert.Contains(t, rendered, "http://192.168.0.40:4000/api/logs?source_name=gotrue.logs.prod")
618+
})
619+
}
620+
500621
func TestFormatMapForEnvConfig(t *testing.T) {
501622
t.Run("It produces the correct format and removes the trailing comma", func(t *testing.T) {
502623
testcases := []struct {

0 commit comments

Comments
 (0)