Repository: supabase/cli
Pull request #926 (2023-03-09 17:01:41)

feat: initial logflare cli integration
Initial Logflare integration for allowing users to access their logs + charts in supabase studio during local development

Development notes:
- use `INTERNAL_IMAGE_REGISTRY=docker.io go run . start` for pointing to public docker
- to quickly test if locally built docker image works, in logflare repo, run `docker-compose build` and then run above command to use the locally tagged image.


Patch:
diff --git a/.gitignore b/.gitignore
index 53452307..ae912a4e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,5 @@
 node_modules
 package-lock.json
 
+# Initialised by cli for local testing
+supabase/
diff --git a/internal/db/start/start.go b/internal/db/start/start.go
index a3d31bfa..df78ef02 100644
--- a/internal/db/start/start.go
+++ b/internal/db/start/start.go
@@ -62,12 +62,13 @@ func StartDatabase(ctx context.Context, fsys afero.Fs, w io.Writer, options ...f
 		}
 	}
 	hostPort := strconv.FormatUint(uint64(utils.Config.Db.Port), 10)
-	hostConfig := container.HostConfig{
+	hostConfig := WithSyslogConfig(container.HostConfig{
 		PortBindings:  nat.PortMap{"5432/tcp": []nat.PortBinding{{HostPort: hostPort}}},
 		RestartPolicy: container.RestartPolicy{Name: "always"},
 		Binds:         []string{utils.DbId + ":/var/lib/postgresql/data"},
 		Tmpfs:         map[string]string{"/docker-entrypoint-initdb.d": ""},
-	}
+	})
+
 	fmt.Fprintln(w, "Starting database...")
 	// Creating volume will not override existing volume, so we must inspect explicitly
 	_, err := utils.Docker.VolumeInspect(ctx, utils.DbId)
@@ -83,6 +84,17 @@ func StartDatabase(ctx context.Context, fsys afero.Fs, w io.Writer, options ...f
 	return initDatabase(ctx, fsys, w, options...)
 }
 
+func WithSyslogConfig(hostConfig container.HostConfig) container.HostConfig {
+	if utils.Config.Analytics.Enabled {
+		hostConfig.LogConfig.Type = "syslog"
+		hostConfig.LogConfig.Config = map[string]string{
+			"syslog-address": fmt.Sprintf("tcp://localhost:%d", utils.Config.Analytics.VectorPort),
+			"tag":            "{{.Name}}",
+		}
+	}
+	return hostConfig
+}
+
 func initCurrentBranch(fsys afero.Fs) error {
 	// Create _current_branch file to avoid breaking db branch commands
 	if _, err := fsys.Stat(utils.CurrBranchPath); err == nil || !errors.Is(err, os.ErrNotExist) {
diff --git a/internal/start/start.go b/internal/start/start.go
index 77965177..d4cefb06 100644
--- a/internal/start/start.go
+++ b/internal/start/start.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"path/filepath"
 	"regexp"
 	"strconv"
 	"strings"
@@ -90,6 +91,23 @@ func NewKongConfig() kongConfig {
 	return config
 }
 
+type vectorConfig struct {
+	ApiKey     string
+	LogflareId string
+	KongId     string
+	GotrueId   string
+	RestId     string
+	RealtimeId string
+	StorageId  string
+	DbId       string
+}
+
+var (
+	//go:embed templates/vector.yaml
+	vectorConfigEmbed    string
+	vectorConfigTemplate = template.Must(template.New("vectorConfig").Parse(vectorConfigEmbed))
+)
+
 func run(p utils.Program, ctx context.Context, fsys afero.Fs, excludedContainers []string, options ...func(*pgx.ConnConfig)) error {
 	excluded := make(map[string]bool)
 	for _, name := range excludedContainers {
@@ -115,16 +133,126 @@ func run(p utils.Program, ctx context.Context, fsys afero.Fs, excludedContainers
 		}
 	}
 
+	// Start vector
+	if utils.Config.Analytics.Enabled && !isContainerExcluded(utils.VectorImage, excluded) {
+		var vectorConfigBuf bytes.Buffer
+		if err := vectorConfigTemplate.Execute(&vectorConfigBuf, vectorConfig{
+			ApiKey:     utils.Config.Analytics.ApiKey,
+			LogflareId: utils.LogflareId,
+			KongId:     utils.KongId,
+			GotrueId:   utils.GotrueId,
+			RestId:     utils.RestId,
+			RealtimeId: utils.RealtimeId,
+			StorageId:  utils.StorageId,
+			DbId:       utils.DbId,
+		}); err != nil {
+			return err
+		}
+		p.Send(utils.StatusMsg("Starting syslog driver..."))
+		if _, err := utils.DockerStart(
+			ctx,
+			container.Config{
+				Image: utils.VectorImage,
+				Env: []string{
+					"VECTOR_CONFIG=/etc/vector/vector.yaml",
+				},
+				Entrypoint: []string{"sh", "-c", `cat <<'EOF' > /etc/vector/vector.yaml && vector
+` + vectorConfigBuf.String() + `
+EOF
+`},
+				Healthcheck: &container.HealthConfig{
+					Test: []string{"CMD",
+						"wget",
+						"--no-verbose",
+						"--tries=1",
+						"--spider",
+						"http://localhost:9001/health"},
+					Interval: 2 * time.Second,
+					Timeout:  2 * time.Second,
+					Retries:  10,
+				},
+				ExposedPorts: nat.PortSet{"9000/tcp": {}},
+			},
+			container.HostConfig{
+				PortBindings:  nat.PortMap{"9000/tcp": []nat.PortBinding{{HostPort: strconv.FormatUint(uint64(utils.Config.Analytics.VectorPort), 10)}}},
+				RestartPolicy: container.RestartPolicy{Name: "always"},
+			},
+			utils.VectorId,
+		); err != nil {
+			return err
+		}
+		if err := waitForServiceReady(ctx, []string{utils.VectorId}); err != nil {
+			return err
+		}
+	}
+
 	// Start Postgres.
 	w := utils.StatusWriter{Program: p}
 	if err := start.StartDatabase(ctx, fsys, w, options...); err != nil {
 		return err
 	}
 
-	p.Send(utils.StatusMsg("Starting containers..."))
 	var started []string
+	// Start Logflare
+	if utils.Config.Analytics.Enabled && !isContainerExcluded(utils.LogflareImage, excluded) {
+		workdir, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+		hostJwtPath := filepath.Join(workdir, utils.Config.Analytics.GcpJwtPath)
+		if _, err := utils.DockerStart(
+			ctx,
+			container.Config{
+				Hostname: "127.0.0.1",
+				Image:    utils.LogflareImage,
+				Env: []string{
+					"DB_DATABASE=postgres",
+					"DB_HOSTNAME=" + utils.DbId,
+					"DB_PORT=5432",
+					"DB_SCHEMA=_analytics",
+					"DB_USERNAME=supabase_admin",
+					"DB_PASSWORD=postgres",
+					"LOGFLARE_MIN_CLUSTER_SIZE=1",
+					"LOGFLARE_SINGLE_TENANT=true",
+					"LOGFLARE_SUPABASE_MODE=true",
+					"LOGFLARE_API_KEY=" + utils.Config.Analytics.ApiKey,
+					"LOGFLARE_LOG_LEVEL=warn",
+					// This is hardcoded in studio frontend
+					"GOOGLE_DATASET_ID_APPEND=_prod",
+					"GOOGLE_PROJECT_ID=" + utils.Config.Analytics.GcpProjectId,
+					"GOOGLE_PROJECT_NUMBER=" + utils.Config.Analytics.GcpProjectNumber,
+				},
+				Healthcheck: &container.HealthConfig{
+					Test:        []string{"CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://localhost:4000/health"},
+					Interval:    2 * time.Second,
+					Timeout:     2 * time.Second,
+					Retries:     10,
+					StartPeriod: 10 * time.Second,
+				},
+				ExposedPorts: nat.PortSet{"4000/tcp": {}},
+			},
+			container.HostConfig{
+				Binds:         []string{hostJwtPath + ":/opt/app/rel/logflare/bin/gcloud.json"},
+				PortBindings:  nat.PortMap{"4000/tcp": []nat.PortBinding{{HostPort: strconv.FormatUint(uint64(utils.Config.Analytics.Port), 10)}}},
+				RestartPolicy: container.RestartPolicy{Name: "always"},
+			},
+			utils.LogflareId,
+		); err != nil {
+			return err
+		}
+		startTime := time.Now()
+		timeout := 30 * time.Second
+		// TODO: logflare needs more time to initialise after passing health check
+		_ = reset.RetryEverySecond(ctx, func() bool {
+			progress := time.Since(startTime).Seconds() / timeout.Seconds() * 100
+			p.Send(utils.StatusMsg(fmt.Sprintf("Initialising logflare... %d%%", int(progress))))
+			return false
+		}, timeout)
+		started = append(started, utils.LogflareId)
+	}
 
 	// Start Kong.
+	p.Send(utils.StatusMsg("Starting containers..."))
 	if !isContainerExcluded(utils.KongImage, excluded) {
 		var kongConfigBuf bytes.Buffer
 		if err := kongConfigTemplate.Execute(&kongConfigBuf, NewKongConfig()); err != nil {
@@ -151,10 +279,10 @@ func run(p utils.Program, ctx context.Context, fsys afero.Fs, excludedContainers
 EOF
 `},
 			},
-			container.HostConfig{
+			start.WithSyslogConfig(container.HostConfig{
 				PortBindings:  nat.PortMap{"8000/tcp": []nat.PortBinding{{HostPort: strconv.FormatUint(uint64(utils.Config.Api.Port), 10)}}},
 				RestartPolicy: container.RestartPolicy{Name: "always"},
-			},
+			}),
 			utils.KongId,
 		); err != nil {
 			return err
@@ -240,9 +368,9 @@ EOF
 					Retries:  10,
 				},
 			},
-			container.HostConfig{
+			start.WithSyslogConfig(container.HostConfig{
 				RestartPolicy: container.RestartPolicy{Name: "always"},
-			},
+			}),
 			utils.GotrueId,
 		); err != nil {
 			return err
@@ -310,9 +438,9 @@ EOF
 					Retries:  10,
 				},
 			},
-			container.HostConfig{
+			start.WithSyslogConfig(container.HostConfig{
 				RestartPolicy: container.RestartPolicy{Name: "always"},
-			},
+			}),
 			utils.RealtimeId,
 		); err != nil {
 			return err
@@ -335,9 +463,9 @@ EOF
 				},
 				// PostgREST does not expose a shell for health check
 			},
-			container.HostConfig{
+			start.WithSyslogConfig(container.HostConfig{
 				RestartPolicy: container.RestartPolicy{Name: "always"},
-			},
+			}),
 			utils.RestId,
 		); err != nil {
 			return err
@@ -374,10 +502,10 @@ EOF
 					Retries:  10,
 				},
 			},
-			container.HostConfig{
+			start.WithSyslogConfig(container.HostConfig{
 				RestartPolicy: container.RestartPolicy{Name: "always"},
 				Binds:         []string{utils.StorageId + ":/var/lib/storage"},
-			},
+			}),
 			utils.StorageId,
 		); err != nil {
 			return err
@@ -455,6 +583,9 @@ EOF
 					fmt.Sprintf("SUPABASE_PUBLIC_URL=http://localhost:%v/", utils.Config.Api.Port),
 					"SUPABASE_ANON_KEY=" + utils.Config.Auth.AnonKey,
 					"SUPABASE_SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey,
+					"LOGFLARE_API_KEY=" + utils.Config.Analytics.ApiKey,
+					fmt.Sprintf("LOGFLARE_URL=http://%v:4000", utils.LogflareId),
+					fmt.Sprintf("NEXT_PUBLIC_ENABLE_LOGS=%v", utils.Config.Analytics.Enabled),
 				},
 				Healthcheck: &container.HealthConfig{
 					Test:     []string{"CMD", "node", "-e", "require('http').get('http://localhost:3000/api/profile', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"},
diff --git a/internal/start/templates/kong_config b/internal/start/templates/kong_config
index c80ce7fd..2343b6c9 100644
--- a/internal/start/templates/kong_config
+++ b/internal/start/templates/kong_config
@@ -113,6 +113,14 @@ services:
         strip_path: true
         paths:
           - /functions/v1/
+  - name: analytics-v1
+    _comment: "Analytics: /analytics/v1/* -> http://logflare:4000/*"
+    url: http://supabase_analytics_{{ .ProjectId }}:4000/
+    routes:
+      - name: analytics-v1-all
+        strip_path: true
+        paths:
+          - /analytics/v1/
 consumers:
 {{range $key, $val := .ApiKeys}}
   - username: {{$key}}
diff --git a/internal/start/templates/vector.yaml b/internal/start/templates/vector.yaml
new file mode 100644
index 00000000..c2efc1f8
--- /dev/null
+++ b/internal/start/templates/vector.yaml
@@ -0,0 +1,201 @@
+api:
+  enabled: true
+  address: "0.0.0.0:9001"
+
+sources:
+  docker_syslog:
+    type: "syslog"
+    address: "0.0.0.0:9000"
+    mode: "tcp"
+    path: "/tmp/socket"
+
+transforms:
+  project_logs:
+    type: remap
+    inputs:
+      - docker_syslog
+    source: |-
+      .project = "default"
+      .event_message = del(.message)
+      del(.procid)
+      del(.source_id)
+      del(.source_type)
+      del(.facility)
+      del(.host)
+      del(.id)
+  router:
+    type: route
+    inputs:
+      - project_logs
+    route:
+      kong: '.appname == "{{ .KongId }}"'
+      auth: '.appname == "{{ .GotrueId }}"'
+      rest: '.appname == "{{ .RestId }}"'
+      realtime: '.appname == "{{ .RealtimeId }}"'
+      storage: '.appname == "{{ .StorageId }}"'
+      db: '.appname == "{{ .DbId }}"'
+  # Kong logs only include api requests
+  kong_logs:
+    type: remap
+    inputs:
+      - router.kong
+    source: |-
+      req, err = parse_nginx_log(.event_message, "combined")
+      if err == null {
+          .timestamp = req.timestamp
+          .metadata.request.headers.referer = req.referer
+          .metadata.request.headers.user_agent = req.agent
+          .metadata.request.headers.cf_connecting_ip = req.client
+          .metadata.request.method = req.method
+          .metadata.request.path = req.path
+          .metadata.request.protocol = req.protocol
+          .metadata.response.status_code = req.status
+      }
+  # TODO: create a separate page and filter for kong error logs
+  kong_err:
+    type: remap
+    inputs:
+      - router.kong
+    source: |-
+      .metadata.request.method = "GET"
+      .metadata.response.status_code = 200
+      parsed, err = parse_nginx_log(.event_message, "error")
+      if err == null {
+          .timestamp = parsed.timestamp
+          .severity = parsed.severity
+          .metadata.request.host = parsed.host
+          .metadata.request.headers.cf_connecting_ip = parsed.client
+          url, err = split(parsed.request, " ")
+          if err == null {
+              .metadata.request.method = url[0]
+              .metadata.request.path = url[1]
+              .metadata.request.protocol = url[2]
+          }
+      }
+  # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency.
+  auth_logs:
+    type: remap
+    inputs:
+      - router.auth
+    source: |-
+      parsed, err = parse_json(.event_message)
+      if err == null {
+          .metadata.timestamp = parsed.time
+          .metadata = merge!(.metadata, parsed)
+      }
+  # PostgREST logs are structured so we separate timestamp from message using regex
+  rest_logs:
+    type: remap
+    inputs:
+      - router.rest
+    source: |-
+      parsed, err = parse_regex(.event_message, r'^(?P<time>.*): (?P<msg>.*)$')
+      if err == null {
+          .event_message = parsed.msg
+          .timestamp = to_timestamp!(parsed.time)
+          .metadata.host = .project
+      }
+  # Realtime logs are structured so we parse the severity level using regex (ignore time because it has no date)
+  realtime_logs:
+    type: remap
+    inputs:
+      - router.realtime
+    source: |-
+      .metadata.project = del(.project)
+      .metadata.external_id = .metadata.project
+      parsed, err = parse_regex(.event_message, r'^(?P<time>\d+:\d+:\d+\.\d+) \[(?P<level>\w+)\] (?P<msg>.*)$')
+      if err == null {
+          .event_message = parsed.msg
+          .metadata.level = parsed.level
+      }
+  # Storage logs may contain json objects so we parse them for completeness
+  storage_logs:
+    type: remap
+    inputs:
+      - router.storage
+    source: |-
+      .metadata.project = del(.project)
+      .metadata.tenantId = .metadata.project
+      parsed, err = parse_json(.event_message)
+      if err == null {
+          .event_message = parsed.msg
+          .metadata.level = parsed.level
+          .metadata.timestamp = parsed.time
+          .metadata.context[0].host = parsed.hostname
+          .metadata.context[0].pid = parsed.pid
+      }
+  # Postgres logs some messages to stderr which we map to warning severity level
+  # TODO: parse raw postgres logs via regex
+  db_logs:
+    type: remap
+    inputs:
+      - router.db
+    source: |-
+      .metadata.host = "db-default"
+      .metadata.parsed.timestamp = .timestamp
+      .metadata.parsed.error_severity = replace!(.severity, r'^err$', "warning")
+
+sinks:
+  logflare_auth:
+    type: "http"
+    inputs:
+      - auth_logs
+    encoding:
+      codec: "json"
+    method: "post"
+    request:
+      retry_max_duration_secs: 10
+    uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=gotrue.logs.prod&api_key={{ .ApiKey }}"
+  logflare_realtime:
+    type: "http"
+    inputs:
+      - realtime_logs
+    encoding:
+      codec: "json"
+    method: "post"
+    request:
+      retry_max_duration_secs: 10
+    uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=realtime.logs.prod&api_key={{ .ApiKey }}"
+  logflare_rest:
+    type: "http"
+    inputs:
+      - rest_logs
+    encoding:
+      codec: "json"
+    method: "post"
+    request:
+      retry_max_duration_secs: 10
+    uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=postgREST.logs.prod&api_key={{ .ApiKey }}"
+  logflare_db:
+    type: "http"
+    inputs:
+      - db_logs
+    encoding:
+      codec: "json"
+    method: "post"
+    request:
+      retry_max_duration_secs: 10
+    # We must route the sink through kong because ingesting logs before logflare is fully initialised will
+    # lead to broken queries from studio. This works by the assumption that containers are started in the
+    # following order: vector > db > logflare > kong
+    uri: "http://{{ .KongId }}:8000/analytics/v1/api/logs?source_name=postgres.logs&api_key={{ .ApiKey }}"
+  logflare_storage:
+    type: "http"
+    inputs:
+      - storage_logs
+    encoding:
+      codec: "json"
+    method: "post"
+    request:
+      retry_max_duration_secs: 10
+    uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=storage.logs.prod.2&api_key={{ .ApiKey }}"
+  logflare_kong:
+    type: "http"
+    inputs:
+      - kong_logs
+    encoding:
+      codec: "json"
+    method: "post"
+    request:
+      retry_max_duration_secs: 10
+    uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=cloudflare.logs.prod&api_key={{ .ApiKey }}"
diff --git a/internal/status/status.go b/internal/status/status.go
index 32f31858..6a5b7823 100644
--- a/internal/status/status.go
+++ b/internal/status/status.go
@@ -74,6 +74,7 @@ func Run(ctx context.Context, names CustomName, format string, fsys afero.Fs) er
 		utils.ImgProxyId,
 		utils.PgmetaId,
 		utils.StudioId,
+		utils.LogflareId,
 	}
 	stopped := checkServiceHealth(ctx, services, os.Stderr)
 	if len(stopped) > 0 {
diff --git a/internal/utils/config.go b/internal/utils/config.go
index caae461f..b0327938 100644
--- a/internal/utils/config.go
+++ b/internal/utils/config.go
@@ -31,6 +31,8 @@ var (
 	PgmetaId    string
 	StudioId    string
 	DenoRelayId string
+	LogflareId  string
+	VectorId    string
 
 	InitialSchemaSql string
 	//go:embed templates/initial_schemas/13.sql
@@ -88,6 +90,7 @@ type (
 		Storage   storage             `toml:"storage"`
 		Auth      auth                `toml:"auth" mapstructure:"auth"`
 		Functions map[string]function `toml:"functions"`
+		Analytics analytics           `toml:"analytics"`
 		// TODO
 		// Scripts   scripts
 	}
@@ -151,6 +154,16 @@ type (
 		ImportMap string `toml:"import_map"`
 	}
 
+	analytics struct {
+		Enabled          bool   `toml:"enabled"`
+		Port             uint16 `toml:"port"`
+		VectorPort       uint16 `toml:"vector_port"`
+		GcpProjectId     string `toml:"gcp_project_id"`
+		GcpProjectNumber string `toml:"gcp_project_number"`
+		GcpJwtPath       string `toml:"gcp_jwt_path"`
+		ApiKey           string `toml:"-" mapstructure:"api_key"`
+	}
+
 	// TODO
 	// scripts struct {
 	// 	BeforeMigrations string `toml:"before_migrations"`
@@ -198,6 +211,8 @@ func LoadConfigFS(fsys afero.Fs) error {
 			PgmetaId = "supabase_pg_meta_" + Config.ProjectId
 			StudioId = "supabase_studio_" + Config.ProjectId
 			DenoRelayId = "supabase_deno_relay_" + Config.ProjectId
+			LogflareId = "supabase_analytics_" + Config.ProjectId
+			VectorId = "supabase_vector_" + Config.ProjectId
 		}
 		if Config.Api.Port == 0 {
 			return errors.New("Missing required field in config: api.port")
@@ -366,6 +381,27 @@ func LoadConfigFS(fsys afero.Fs) error {
 		}
 	}
 
+	if Config.Analytics.Enabled {
+		if Config.Analytics.Port == 0 {
+			Config.Analytics.Port = 54327
+		}
+		if Config.Analytics.VectorPort == 0 {
+			Config.Analytics.VectorPort = 54328
+		}
+		if len(Config.Analytics.GcpProjectId) == 0 {
+			return errors.New("Missing required field in config: analytics.gcp_project_id")
+		}
+		if len(Config.Analytics.GcpProjectNumber) == 0 {
+			return errors.New("Missing required field in config: analytics.gcp_project_number")
+		}
+		if len(Config.Analytics.GcpJwtPath) == 0 {
+			Config.Analytics.GcpJwtPath = "supabase/gcloud.json"
+		}
+		if len(Config.Analytics.ApiKey) == 0 {
+			Config.Analytics.ApiKey = "api-key"
+		}
+	}
+
 	return nil
 }
 
diff --git a/internal/utils/misc.go b/internal/utils/misc.go
index 15fa5617..0f7e9d09 100644
--- a/internal/utils/misc.go
+++ b/internal/utils/misc.go
@@ -37,15 +37,17 @@ const (
 	DifferImage      = "supabase/pgadmin-schema-diff:cli-0.0.5"
 	MigraImage       = "djrobstep/migra:3.0.1621480950"
 	PgmetaImage      = "supabase/postgres-meta:v0.60.7"
-	StudioImage      = "supabase/studio:20230216-e731b77"
+	StudioImage      = "supabase/studio:20230330-99fed3d"
 	DenoRelayImage   = "supabase/deno-relay:v1.6.0"
 	ImageProxyImage  = "darthsim/imgproxy:v3.8.0"
 	EdgeRuntimeImage = "supabase/edge-runtime:v1.1.7"
+	VectorImage      = "timberio/vector:0.28.1-alpine"
 	// Update initial schemas in internal/utils/templates/initial_schemas when
 	// updating any one of these.
 	GotrueImage   = "supabase/gotrue:v2.51.4"
 	RealtimeImage = "supabase/realtime:v2.10.1"
 	StorageImage  = "supabase/storage-api:v0.29.1"
+	LogflareImage = "supabase/logflare:1.0.0"
 	// Should be kept in-sync with DenoRelayImage
 	DenoVersion = "1.30.3"
 )
@@ -63,6 +65,8 @@ var ServiceImages = []string{
 	PgmetaImage,
 	StudioImage,
 	DenoRelayImage,
+	LogflareImage,
+	VectorImage,
 }
 
 func ShortContainerImageName(imageName string) string {
@@ -134,6 +138,7 @@ var (
 		"realtime",
 		"_realtime",
 		"storage",
+		"_analytics",
 		"supabase_functions",
 		"supabase_migrations",
 	}, SystemSchemas...)
diff --git a/internal/utils/templates/init_config.toml b/internal/utils/templates/init_config.toml
index 42074557..9b6ead51 100644
--- a/internal/utils/templates/init_config.toml
+++ b/internal/utils/templates/init_config.toml
@@ -70,3 +70,13 @@ redirect_uri = ""
 # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
 # or any other third-party OIDC providers.
 url = ""
+
+[analytics]
+enabled = false
+port = 54327
+vector_port = 54328
+# Setup BigQuery project to enable log viewer on local development stack.
+# See: https://logflare.app/guides/bigquery-setup
+gcp_project_id = ""
+gcp_project_number = ""
+gcp_jwt_path = "supabase/gcloud.json"
diff --git a/internal/utils/templates/initial_schemas/13.sql b/internal/utils/templates/initial_schemas/13.sql
index 38ba4397..70ce2029 100644
--- a/internal/utils/templates/initial_schemas/13.sql
+++ b/internal/utils/templates/initial_schemas/13.sql
@@ -25,6 +25,15 @@ CREATE SCHEMA IF NOT EXISTS _realtime;
 
 ALTER SCHEMA _realtime OWNER TO postgres;
 
+--
+-- Name: _analytics; Type: SCHEMA; Schema: -; Owner: postgres
+--
+
+CREATE SCHEMA IF NOT EXISTS _analytics;
+
+
+ALTER SCHEMA _analytics OWNER TO postgres;
+
 --
 -- Name: auth; Type: SCHEMA; Schema: -; Owner: supabase_admin
 --
diff --git a/internal/utils/templates/initial_schemas/14.sql b/internal/utils/templates/initial_schemas/14.sql
index 0143a79e..f4e0a70c 100644
--- a/internal/utils/templates/initial_schemas/14.sql
+++ b/internal/utils/templates/initial_schemas/14.sql
@@ -25,6 +25,15 @@ CREATE SCHEMA IF NOT EXISTS _realtime;
 
 ALTER SCHEMA _realtime OWNER TO postgres;
 
+--
+-- Name: _analytics; Type: SCHEMA; Schema: -; Owner: postgres
+--
+
+CREATE SCHEMA IF NOT EXISTS _analytics;
+
+
+ALTER SCHEMA _analytics OWNER TO postgres;
+
 --
 -- Name: auth; Type: SCHEMA; Schema: -; Owner: supabase_admin
 --
diff --git a/internal/utils/templates/initial_schemas/15.sql b/internal/utils/templates/initial_schemas/15.sql
index 585496bc..7540f7bf 100644
--- a/internal/utils/templates/initial_schemas/15.sql
+++ b/internal/utils/templates/initial_schemas/15.sql
@@ -25,6 +25,15 @@ CREATE SCHEMA IF NOT EXISTS _realtime;
 
 ALTER SCHEMA _realtime OWNER TO postgres;
 
+--
+-- Name: _analytics; Type: SCHEMA; Schema: -; Owner: postgres
+--
+
+CREATE SCHEMA IF NOT EXISTS _analytics;
+
+
+ALTER SCHEMA _analytics OWNER TO postgres;
+
 --
 -- Name: auth; Type: SCHEMA; Schema: -; Owner: supabase_admin
 --
diff --git a/internal/utils/templates/initial_schemas/README.md b/internal/utils/templates/initial_schemas/README.md
index 1ba7f07e..333f6f23 100644
--- a/internal/utils/templates/initial_schemas/README.md
+++ b/internal/utils/templates/initial_schemas/README.md
@@ -6,7 +6,7 @@ The initial schema for PG12 is not available because the latest image (`supabase
 
 # Why use the pg_dump output instead of running the `init.sql` directly?
 
-Because Realtime, GoTrue, and Storage have their own migrations, and these need to be included in the initial schema for e.g. `supabase db reset` to work.
+Because Realtime, GoTrue, Logflare, and Storage have their own migrations, and these need to be included in the initial schema for e.g. `supabase db reset` to work.
 
 # How to Generate Initial Schemas
 
@@ -84,6 +84,19 @@ services:
       PGRST_JWT_SECRET: a
       FILE_SIZE_LIMIT: a
       STORAGE_BACKEND: a
+  
+  analytics:
+    container_name: supabase-analytics
+    image: supabase/logflare:1.0.0
+    depends_on:
+      - db
+    restart: unless-stopped
+    environment:
+      DB_DATABASE: analytics
+      DB_HOSTNAME: db
+      DB_PORT: 5432
+      DB_PASSWORD: postgres
+      DB_USERNAME: postgres
 ```
 
 - Copy `/internal/utils/templates/globals.sql` to `./globals.sql`
@@ -94,6 +107,10 @@ services:
 -- 00-initial-schema.sql
 --
 
+-- Set up logflare
+ALTER SYSTEM SET wal_level = 'logical';
+create schema if not exists analytics;
+
 -- Set up realtime
 -- create publication supabase_realtime; -- defaults to empty publication
 create publication supabase_realtime;


Test patch:
diff --git a/internal/start/start_test.go b/internal/start/start_test.go
index 5412d406..a1b972ed 100644
--- a/internal/start/start_test.go
+++ b/internal/start/start_test.go
@@ -175,6 +175,8 @@ func TestDatabaseStart(t *testing.T) {
 		apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.PgmetaImage), utils.PgmetaId)
 		utils.StudioId = "test-studio"
 		apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.StudioImage), utils.StudioId)
+		utils.LogflareId = "test-logflare"
+		apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.LogflareImage), utils.LogflareId)
 		// Setup mock postgres
 		utils.GlobalsSql = "create schema public"
 		utils.InitialSchemaSql = "create schema private"
@@ -187,7 +189,7 @@ func TestDatabaseStart(t *testing.T) {
 		// Setup health probes
 		started := []string{
 			utils.DbId, utils.KongId, utils.GotrueId, utils.InbucketId, utils.RealtimeId,
-			utils.StorageId, utils.ImgProxyId, utils.PgmetaId, utils.StudioId,
+			utils.StorageId, utils.ImgProxyId, utils.PgmetaId, utils.StudioId, utils.LogflareId,
 		}
 		for _, container := range started {
 			gock.New(utils.Docker.DaemonHost()).
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index 50c8a181..4393fcb9 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -32,6 +32,7 @@ func TestStatusCommand(t *testing.T) {
 			"storage_imgproxy_",
 			"supabase_pg_meta_",
 			"supabase_studio_",
+			"supabase_analytics_",
 		}
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()