feat: add env example, chart config, and track lib

This commit is contained in:
zhenyi 2026-06-01 22:05:03 +08:00
parent 9bc0e742bc
commit c7c490bf77
18 changed files with 2025 additions and 0 deletions

65
.env.example Normal file
View File

@ -0,0 +1,65 @@
# ============================================================
# GitDataAI Development Environment Variables
# Copy to .env and adjust as needed
# ============================================================
# ── Database ───────────────────────────────────────────────
POSTGRES_USER=gitdata
POSTGRES_PASSWORD=gitdata123
POSTGRES_DB=app
# ── MinIO (S3-compatible storage) ──────────────────────────
MINIO_ROOT_USER=admin
MINIO_ROOT_PASSWORD=mysecret123
# ── Application ────────────────────────────────────────────
APP_API_PORT=8080
APP_NAME=gitdata
APP_DOMAIN_URL=http://localhost
APP_SESSION_SECRET=supersecretdevkey123
# ── Database Connection ────────────────────────────────────
APP_DATABASE_URL=postgres://gitdata:gitdata123@localhost:5432/app
# ── Redis ──────────────────────────────────────────────────
APP_REDIS_URLS=redis://localhost:6379
# ── Qdrant Vector DB ───────────────────────────────────────
APP_QDRANT_URL=http://localhost:6333
# ── NATS ───────────────────────────────────────────────────
NATS_URL=nats://localhost:4222
# ── Storage (S3) ───────────────────────────────────────────
APP_STORAGE_BACKEND=s3
APP_STORAGE_S3_BUCKET=gitdata
APP_STORAGE_S3_REGION=us-east-1
APP_STORAGE_S3_ENDPOINT_URL=http://localhost:9000
APP_STORAGE_S3_ACCESS_KEY_ID=admin
APP_STORAGE_S3_SECRET_ACCESS_KEY=mysecret123
APP_STORAGE_S3_FORCE_PATH_STYLE=true
# ── Git Service (gitpod) ───────────────────────────────────
APP_GIT_RPC_ADDR=0.0.0.0
APP_GIT_RPC_PORT=5030
APP_GIT_HTTP_PORT=5023
APP_SSH_PORT=5022
# ── GitSync Health ─────────────────────────────────────────
APP_GITSYNC_HEALTH_PORT=5083
# ── SMTP (Email) ───────────────────────────────────────────
# For development, use MailHog: docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
APP_SMTP_HOST=localhost
APP_SMTP_PORT=1025
APP_SMTP_USERNAME=dev
APP_SMTP_PASSWORD=dev
APP_SMTP_FROM=Gitdata <noreply@localhost>
# ── AI / Embed ─────────────────────────────────────────────
APP_AI_BASIC_URL=http://localhost:11434/v1
APP_AI_API_KEY=ollama
APP_EMBED_MODEL_BASE_URL=http://localhost:11434/v1
APP_EMBED_MODEL_API_KEY=ollama
APP_EMBED_MODEL_NAME=nomic-embed-text
APP_EMBED_MODEL_DIMENSIONS=768

0
chart/app/.helmignore Normal file
View File

17
chart/app/Chart.yaml Normal file
View File

@ -0,0 +1,17 @@
apiVersion: v2
name: gitdataai
description: GitDataAI — Git platform with AI-powered code agents
type: application
version: 0.2.0
appVersion: "1.0.0"
keywords:
- git
- ai
- agent
- code-review
- cicd
maintainers:
- name: gitdataai-team

View File

@ -0,0 +1,11 @@
{{- $svcNames := list "gitdata" "gitpod" "gitsync" "email" }}
{{- range $svcName := $svcNames }}
{{- $svcCfg := index $.Values $svcName }}
{{- if and $svcCfg.enabled (not (eq $svcName "email")) }}
{{- if eq $svcName "gitpod" }}
{{- printf " http://%s-http.%s.svc.cluster.local:%d/" (include "app.serviceFullname" (dict "root" $ "name" $svcName)) (include "app.namespace" $) (int $svcCfg.service.port) }}
{{- else }}
{{- printf " http://%s.%s.svc.cluster.local:%d/" (include "app.serviceFullname" (dict "root" $ "name" $svcName)) (include "app.namespace" $) (int $svcCfg.service.port) }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,125 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "app.name" -}}
{{- default .Chart.Name .Values.global.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "app.fullname" -}}
{{- if .Values.global.fullnameOverride }}
{{- .Values.global.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.global.nameOverride }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "app.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "app.labels" -}}
helm.sh/chart: {{ include "app.chart" . }}
app.kubernetes.io/name: {{ include "app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Service labels.
*/}}
{{- define "app.serviceLabels" -}}
{{- $root := .root }}
{{- $name := .name }}
helm.sh/chart: {{ include "app.chart" $root }}
app.kubernetes.io/name: {{ $name }}
app.kubernetes.io/instance: {{ $root.Release.Name }}
app.kubernetes.io/component: {{ $name }}
{{- if $root.Chart.AppVersion }}
app.kubernetes.io/version: {{ $root.Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ $root.Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "app.serviceSelectorLabels" -}}
{{- $root := .root }}
{{- $name := .name }}
app.kubernetes.io/name: {{ $name }}
app.kubernetes.io/instance: {{ $root.Release.Name }}
app.kubernetes.io/component: {{ $name }}
{{- end }}
{{/*
Fully qualified service name: <release>-<chart>-<serviceName>
*/}}
{{- define "app.serviceFullname" -}}
{{- $root := .root }}
{{- $name := .name }}
{{- if $root.Values.global.fullnameOverride }}
{{- printf "%s-%s" $root.Values.global.fullnameOverride $name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $chartName := default $root.Chart.Name $root.Values.global.nameOverride }}
{{- printf "%s-%s-%s" $root.Release.Name $chartName $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{/*
Namespace.
*/}}
{{- define "app.namespace" -}}
{{- .Values.global.namespace | default .Release.Namespace }}
{{- end }}
{{/*
ServiceAccount name.
*/}}
{{- define "app.serviceAccountName" -}}
{{- if .Values.serviceAccount.name }}
{{- .Values.serviceAccount.name }}
{{- else }}
{{- include "app.fullname" . }}
{{- end }}
{{- end }}
{{/*
Docker image reference.
*/}}
{{- define "app.image" -}}
{{- $globalRegistry := .root.Values.global.image.registry }}
{{- $registry := .svc.registry | default $globalRegistry }}
{{- $name := .svc.name }}
{{- $tag := .svc.tag | default .root.Values.global.image.tag | default "latest" }}
{{- printf "%s/%s:%s" $registry $name $tag }}
{{- end }}
{{/*
Image pull secrets.
*/}}
{{- define "app.imagePullSecrets" -}}
{{- with .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}
{{/*
Gitpod RPC cluster DNS address.
*/}}
{{- define "app.gitpodRpcAddr" -}}
{{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}-rpc.{{ include "app.namespace" . }}
{{- end }}

View File

@ -0,0 +1,28 @@
{{/*
Single shared ConfigMap for all services.
Merges global.env with service-specific overrides.
*/}}
{{- $allEnv := deepCopy ($.Values.global.env | default dict) }}
{{- /* Auto-fill APP_GIT_RPC_ADDR for gitdata -> gitpod-rpc service */}}
{{- if and $.Values.gitdata.enabled (not $.Values.gitdata.env.APP_GIT_RPC_ADDR) }}
{{- $_ := set $allEnv "APP_GIT_RPC_ADDR" (include "app.gitpodRpcAddr" $) }}
{{- end }}
{{- range $svcName, $svc := dict "gitdata" $.Values.gitdata "gitpod" $.Values.gitpod "gitsync" $.Values.gitsync "email" $.Values.email }}
{{- if $svc.enabled }}
{{- $allEnv = merge $allEnv ($svc.env | default dict) }}
{{- end }}
{{- end }}
{{- if $allEnv }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "app.fullname" $ }}
namespace: {{ $.Values.global.namespace | default $.Release.Namespace }}
labels:
{{- include "app.labels" $ | nindent 4 }}
data:
{{- range $k, $v := $allEnv }}
{{ $k }}: {{ $v | quote }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,381 @@
{{/*
Deployments — One per enabled service.
All pods share app-data-pvc mounted at /data.
*/}}
{{/* ============================================================
gitdata — Main API server
============================================================ */}}
{{- if .Values.gitdata.enabled }}
{{- $svc := .Values.gitdata }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "gitdata") }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitdata") | nindent 4 }}
spec:
replicas: {{ $svc.replicaCount }}
selector:
matchLabels:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitdata") | nindent 6 }}
template:
metadata:
annotations:
{{- with $svc.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitdata") | nindent 8 }}
spec:
{{- include "app.imagePullSecrets" . | nindent 6 }}
serviceAccountName: {{ include "app.serviceAccountName" . }}
securityContext:
{{- toYaml $svc.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: {{ include "app.image" (dict "root" . "svc" $svc.image) }}
imagePullPolicy: {{ .Values.global.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "app.fullname" . }}
resources:
{{- toYaml $svc.resources | nindent 12 }}
securityContext:
{{- toYaml $svc.securityContext | nindent 12 }}
volumeMounts:
- name: data
mountPath: /data
{{- with $svc.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
startupProbe:
httpGet:
path: {{ $svc.startupProbe.httpGet.path }}
port: {{ $svc.startupProbe.httpGet.port }}
initialDelaySeconds: {{ $svc.startupProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.startupProbe.periodSeconds }}
failureThreshold: {{ $svc.startupProbe.failureThreshold }}
livenessProbe:
httpGet:
path: {{ $svc.livenessProbe.httpGet.path }}
port: {{ $svc.livenessProbe.httpGet.port }}
periodSeconds: {{ $svc.livenessProbe.periodSeconds }}
readinessProbe:
httpGet:
path: {{ $svc.readinessProbe.httpGet.path }}
port: {{ $svc.readinessProbe.httpGet.port }}
periodSeconds: {{ $svc.readinessProbe.periodSeconds }}
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data-pvc
{{- with $svc.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{/* ============================================================
gitpod — Git protocol server (HTTP + SSH + gRPC)
============================================================ */}}
{{- if .Values.gitpod.enabled }}
{{- $svc := .Values.gitpod }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 4 }}
spec:
replicas: {{ $svc.replicaCount }}
selector:
matchLabels:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitpod") | nindent 6 }}
template:
metadata:
annotations:
{{- with $svc.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 8 }}
spec:
{{- include "app.imagePullSecrets" . | nindent 6 }}
serviceAccountName: {{ include "app.serviceAccountName" . }}
securityContext:
{{- toYaml $svc.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: {{ include "app.image" (dict "root" . "svc" $svc.image) }}
imagePullPolicy: {{ .Values.global.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: ssh
containerPort: 2222
protocol: TCP
- name: grpc
containerPort: 50051
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "app.fullname" . }}
resources:
{{- toYaml $svc.resources | nindent 12 }}
securityContext:
{{- toYaml $svc.securityContext | nindent 12 }}
volumeMounts:
- name: data
mountPath: /data
{{- with $svc.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if $svc.sshHostKeySecret }}
- name: ssh-host-key
mountPath: /etc/ssh
readOnly: true
{{- end }}
startupProbe:
httpGet:
path: {{ $svc.startupProbe.httpGet.path }}
port: {{ $svc.startupProbe.httpGet.port }}
initialDelaySeconds: {{ $svc.startupProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.startupProbe.periodSeconds }}
failureThreshold: {{ $svc.startupProbe.failureThreshold }}
livenessProbe:
httpGet:
path: {{ $svc.livenessProbe.httpGet.path }}
port: {{ $svc.livenessProbe.httpGet.port }}
periodSeconds: {{ $svc.livenessProbe.periodSeconds }}
readinessProbe:
httpGet:
path: {{ $svc.readinessProbe.httpGet.path }}
port: {{ $svc.readinessProbe.httpGet.port }}
periodSeconds: {{ $svc.readinessProbe.periodSeconds }}
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data-pvc
{{- if $svc.sshHostKeySecret }}
- name: ssh-host-key
secret:
secretName: {{ $svc.sshHostKeySecret }}
defaultMode: 0600
{{- end }}
{{- with $svc.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{/* ============================================================
gitsync — Git sync worker
============================================================ */}}
{{- if .Values.gitsync.enabled }}
{{- $svc := .Values.gitsync }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "gitsync") }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitsync") | nindent 4 }}
spec:
replicas: {{ $svc.replicaCount }}
selector:
matchLabels:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitsync") | nindent 6 }}
template:
metadata:
annotations:
{{- with $svc.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitsync") | nindent 8 }}
spec:
{{- include "app.imagePullSecrets" . | nindent 6 }}
serviceAccountName: {{ include "app.serviceAccountName" . }}
securityContext:
{{- toYaml $svc.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: {{ include "app.image" (dict "root" . "svc" $svc.image) }}
imagePullPolicy: {{ .Values.global.image.pullPolicy }}
ports:
- name: health
containerPort: 8081
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "app.fullname" . }}
resources:
{{- toYaml $svc.resources | nindent 12 }}
securityContext:
{{- toYaml $svc.securityContext | nindent 12 }}
volumeMounts:
- name: data
mountPath: /data
{{- with $svc.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
startupProbe:
httpGet:
path: {{ $svc.startupProbe.httpGet.path }}
port: {{ $svc.startupProbe.httpGet.port }}
initialDelaySeconds: {{ $svc.startupProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.startupProbe.periodSeconds }}
failureThreshold: {{ $svc.startupProbe.failureThreshold }}
livenessProbe:
httpGet:
path: {{ $svc.livenessProbe.httpGet.path }}
port: {{ $svc.livenessProbe.httpGet.port }}
periodSeconds: {{ $svc.livenessProbe.periodSeconds }}
readinessProbe:
httpGet:
path: {{ $svc.readinessProbe.httpGet.path }}
port: {{ $svc.readinessProbe.httpGet.port }}
periodSeconds: {{ $svc.readinessProbe.periodSeconds }}
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data-pvc
{{- with $svc.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{/* ============================================================
email — Email worker service
============================================================ */}}
{{- if .Values.email.enabled }}
{{- $svc := .Values.email }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "email") }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "email") | nindent 4 }}
spec:
replicas: {{ $svc.replicaCount }}
selector:
matchLabels:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "email") | nindent 6 }}
template:
metadata:
annotations:
{{- with $svc.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "email") | nindent 8 }}
spec:
{{- include "app.imagePullSecrets" . | nindent 6 }}
serviceAccountName: {{ include "app.serviceAccountName" . }}
securityContext:
{{- toYaml $svc.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: {{ include "app.image" (dict "root" . "svc" $svc.image) }}
imagePullPolicy: {{ .Values.global.image.pullPolicy }}
ports:
- name: health
containerPort: 8083
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "app.fullname" . }}
resources:
{{- toYaml $svc.resources | nindent 12 }}
securityContext:
{{- toYaml $svc.securityContext | nindent 12 }}
volumeMounts:
- name: data
mountPath: /data
{{- with $svc.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
startupProbe:
httpGet:
path: {{ $svc.startupProbe.httpGet.path }}
port: {{ $svc.startupProbe.httpGet.port }}
initialDelaySeconds: {{ $svc.startupProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.startupProbe.periodSeconds }}
failureThreshold: {{ $svc.startupProbe.failureThreshold }}
livenessProbe:
httpGet:
path: {{ $svc.livenessProbe.httpGet.path }}
port: {{ $svc.livenessProbe.httpGet.port }}
periodSeconds: {{ $svc.livenessProbe.periodSeconds }}
readinessProbe:
httpGet:
path: {{ $svc.readinessProbe.httpGet.path }}
port: {{ $svc.readinessProbe.httpGet.port }}
periodSeconds: {{ $svc.readinessProbe.periodSeconds }}
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data-pvc
{{- with $svc.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,38 @@
{{- range $svcName := list "gitdata" "gitpod" "gitsync" "email" }}
{{- $svcCfg := index $.Values $svcName }}
{{- $hpaCfg := index $.Values.autoscaling $svcName }}
{{- if and $svcCfg.enabled $hpaCfg.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "app.serviceFullname" (dict "root" $ "name" $svcName) }}
namespace: {{ include "app.namespace" $ }}
labels:
{{- include "app.serviceLabels" (dict "root" $ "name" $svcName) | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "app.serviceFullname" (dict "root" $ "name" $svcName) }}
minReplicas: {{ $hpaCfg.minReplicas }}
maxReplicas: {{ $hpaCfg.maxReplicas }}
metrics:
{{- if $hpaCfg.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ $hpaCfg.targetCPUUtilizationPercentage }}
{{- end }}
{{- if $hpaCfg.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ $hpaCfg.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,61 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "app.fullname" . }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.api.tls }}
tls:
{{- range .Values.ingress.api.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- range .Values.ingress.git.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.api.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "app.serviceFullname" (dict "root" $ "name" "gitdata") }}
port:
number: {{ $.Values.gitdata.service.port }}
{{- end }}
{{- end }}
{{- range .Values.ingress.git.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "app.serviceFullname" (dict "root" $ "name" "gitpod") }}-http
port:
number: {{ $.Values.gitpod.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,21 @@
{{- if .Values.podDisruptionBudget.enabled }}
{{- range $svcName := list "gitdata" "gitpod" "gitsync" "email" }}
{{- $svcCfg := index $.Values $svcName }}
{{- $pdbCfg := index $.Values.podDisruptionBudget $svcName }}
{{- if and $svcCfg.enabled $pdbCfg.minAvailable }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "app.serviceFullname" (dict "root" $ "name" $svcName) }}
namespace: {{ $.Values.global.namespace | default $.Release.Namespace }}
labels:
{{- include "app.serviceLabels" (dict "root" $ "name" $svcName) | nindent 4 }}
spec:
minAvailable: {{ $pdbCfg.minAvailable }}
selector:
matchLabels:
{{- include "app.serviceSelectorLabels" (dict "root" $ "name" $svcName) | nindent 6 }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,141 @@
{{/*
Generate Services for each enabled service.
*/}}
{{- if .Values.gitdata.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "gitdata") }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitdata") | nindent 4 }}
{{- with .Values.gitdata.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.gitdata.service.type }}
ports:
- port: {{ .Values.gitdata.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitdata") | nindent 4 }}
{{- end }}
{{- if .Values.gitpod.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}-http
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 4 }}
{{- with .Values.gitpod.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.gitpod.service.type }}
ports:
- port: {{ .Values.gitpod.service.port | default 8080 }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitpod") | nindent 4 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}-ssh
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 4 }}
{{- with .Values.gitpod.sshService.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.gitpod.sshService.type }}
ports:
- port: {{ .Values.gitpod.sshService.port | default 2222 }}
targetPort: ssh
protocol: TCP
name: ssh
selector:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitpod") | nindent 4 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}-rpc
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 4 }}
{{- with .Values.gitpod.rpcService.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.gitpod.rpcService.type }}
ports:
- port: {{ .Values.gitpod.rpcService.port | default 50051 }}
targetPort: grpc
protocol: TCP
name: grpc
selector:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitpod") | nindent 4 }}
{{- end }}
{{- if .Values.gitsync.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "gitsync") }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "gitsync") | nindent 4 }}
{{- with .Values.gitsync.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.gitsync.service.type }}
ports:
- port: {{ .Values.gitsync.service.port | default 8081 }}
targetPort: health
protocol: TCP
name: health
selector:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitsync") | nindent 4 }}
{{- end }}
{{- if and .Values.email.enabled .Values.email.service.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "app.serviceFullname" (dict "root" . "name" "email") }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.serviceLabels" (dict "root" . "name" "email") | nindent 4 }}
{{- with .Values.email.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.email.service.type }}
ports:
- port: {{ .Values.email.service.port | default 8083 }}
targetPort: health
protocol: TCP
name: health
selector:
{{- include "app.serviceSelectorLabels" (dict "root" . "name" "email") | nindent 4 }}
{{- end }}

View File

@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "app.serviceAccountName" . }}
namespace: {{ include "app.namespace" . }}
labels:
{{- include "app.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,36 @@
{{- if .Values.serviceMonitor.enabled }}
{{- $svcNames := list "gitdata" "gitpod" "gitsync" "email" }}
{{- range $svcName := $svcNames }}
{{- $svcCfg := index $.Values $svcName }}
{{- $monitorCfg := index $.Values.serviceMonitor.services $svcName }}
{{- if and $svcCfg.enabled $monitorCfg }}
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "app.serviceFullname" (dict "root" $ "name" $svcName) }}
namespace: {{ include "app.namespace" $ }}
labels:
{{- include "app.serviceLabels" (dict "root" $ "name" $svcName) | nindent 4 }}
{{- with $.Values.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with $.Values.serviceMonitor.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
endpoints:
- interval: {{ $.Values.serviceMonitor.interval }}
port: {{ if eq $svcName "gitdata" }}http{{ else if eq $svcName "gitpod" }}http{{ else }}health{{ end }}
{{- if eq $svcName "gitdata" }}
path: /metrics
{{- else }}
path: /health
{{- end }}
selector:
matchLabels:
{{- include "app.serviceSelectorLabels" (dict "root" $ "name" $svcName) | nindent 6 }}
{{- end }}
{{- end }}
{{- end }}

365
chart/app/values.yaml Normal file
View File

@ -0,0 +1,365 @@
global:
image:
registry: "harbor.gitdata.me/app"
pullPolicy: IfNotPresent
tag: "latest"
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
namespace: "gitdataai"
serviceAccount:
create: true
annotations: {}
name: ""
gitdata:
enabled: true
replicaCount: 1
image:
name: gitdata-gitdata
registry: ""
tag: ""
env:
APP_API_PORT: "8080"
APP_OTEL_SERVICE_NAME: "gitdata-api"
APP_GIT_RPC_ADDR: ""
APP_GIT_RPC_PORT: "50051"
service:
type: ClusterIP
port: 8080
annotations: {}
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
startupProbe:
httpGet:
path: /metrics
port: http
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /metrics
port: http
periodSeconds: 15
readinessProbe:
httpGet:
path: /metrics
port: http
periodSeconds: 10
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
securityContext:
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
nodeSelector: {}
tolerations: []
affinity: {}
volumes: []
volumeMounts: []
gitpod:
enabled: true
replicaCount: 1
image:
name: gitdata-gitpod
registry: ""
tag: ""
env:
APP_GIT_HTTP_PORT: "8080"
APP_SSH_PORT: "2222"
APP_GIT_RPC_ADDR: "0.0.0.0"
APP_GIT_RPC_PORT: "50051"
APP_OTEL_SERVICE_NAME: "gitpod"
APP_SSH_DOMAIN: ""
APP_GIT_HTTP_DOMAIN: ""
APP_REPOS_ROOT: "/data/repos"
service:
type: ClusterIP
port: 8080
annotations: {}
sshService:
type: LoadBalancer
port: 2222
annotations: {}
rpcService:
type: ClusterIP
port: 50051
annotations: {}
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 2000m
memory: 2Gi
startupProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 15
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
securityContext:
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
nodeSelector: {}
tolerations: []
affinity: {}
# -- SSH host key secret (mount to /etc/ssh)
sshHostKeySecret: ""
# -- Data volumes (repos storage)
volumes: []
volumeMounts: []
gitsync:
enabled: true
replicaCount: 1
image:
name: gitdata-gitsync
registry: ""
tag: ""
env:
APP_GITSYNC_HEALTH_PORT: "8081"
APP_OTEL_SERVICE_NAME: "gitsync"
APP_REPOS_ROOT: "/data/repos"
service:
type: ClusterIP
port: 8081
annotations: {}
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
startupProbe:
httpGet:
path: /health
port: health
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /health
port: health
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: health
periodSeconds: 15
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
securityContext:
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
nodeSelector: {}
tolerations: []
affinity: {}
volumes: []
volumeMounts: []
email:
enabled: true
replicaCount: 1
image:
name: gitdata-email
registry: ""
tag: ""
env:
APP_EMAIL_HEALTH_PORT: "8083"
APP_OTEL_SERVICE_NAME: "email-service"
service:
enabled: false
type: ClusterIP
port: 8083
annotations: {}
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
startupProbe:
httpGet:
path: /health
port: health
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /health
port: health
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: health
periodSeconds: 15
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
securityContext:
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
nodeSelector: {}
tolerations: []
affinity: {}
volumes: []
volumeMounts: []
ingress:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "cloudflare-acme-cluster-issuer"
api:
hosts:
- host: dev.gitdata.ai
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- dev.gitdata.ai
secretName: dev-gitdata-ai-tls
git:
hosts:
- host: gitdev.gitdata.ai
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- gitdev.gitdata.ai
secretName: gitdev-gitdata-ai-tls
serviceMonitor:
enabled: false
interval: 30s
labels: {}
annotations: {}
services:
gitdata: true
gitpod: true
gitsync: true
email: true
autoscaling:
gitdata:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: ""
gitpod:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 75
targetMemoryUtilizationPercentage: ""
gitsync:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: ""
email:
enabled: false
minReplicas: 1
maxReplicas: 3
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: ""
podDisruptionBudget:
enabled: false
gitdata:
minAvailable: 1
gitpod:
minAvailable: 1
gitsync:
minAvailable: ""
email:
minAvailable: ""

34
lib/track/Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[package]
name = "track"
version.workspace = true
edition.workspace = true
authors.workspace = true
description.workspace = true
repository.workspace = true
readme.workspace = true
homepage.workspace = true
license.workspace = true
keywords.workspace = true
categories.workspace = true
documentation.workspace = true
[lib]
path = "lib.rs"
name = "track"
[dependencies]
config = { workspace = true }
opentelemetry = { version = "0.32.0", features = ["trace", "logs", "metrics"] }
opentelemetry_sdk = { version = "0.32.1", features = ["trace", "logs", "metrics", "rt-tokio"] }
opentelemetry-otlp = { version = "0.32.0", features = ["http-proto", "trace", "logs", "metrics", "reqwest-client", "tls-roots"] }
opentelemetry-appender-tracing = "0.32.0"
opentelemetry-prometheus = "0.32.0"
prometheus = "0.13"
tokio = { version = "1", features = ["full"] }
tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter", "json"] }
tracing-appender = "0.2"
anyhow = { workspace = true }
tracing-opentelemetry = "0.33.0"
tracing = "0.1.41"
[lints]
workspace = true

10
lib/track/lib.rs Normal file
View File

@ -0,0 +1,10 @@
pub mod metrics;
mod otel;
pub use metrics::{MetricsRegistry, record_otel_counter};
pub use otel::LgtmGuard as OtelGuard;
pub use otel::LgtmGuard;
pub use otel::init_lgtm;
// Re-export prometheus types so downstream crates only need `track`.
pub use prometheus::{Counter, CounterVec, Gauge, Histogram, HistogramVec};

249
lib/track/metrics.rs Normal file
View File

@ -0,0 +1,249 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use opentelemetry::KeyValue;
use prometheus::{
CounterVec, Encoder, Gauge, HistogramOpts, HistogramVec, Opts, Registry,
TextEncoder,
};
#[derive(Clone)]
pub struct MetricsRegistry {
inner: Arc<MetricsRegistryInner>,
}
struct MetricsRegistryInner {
registry: Registry,
counters: Mutex<HashMap<String, prometheus::Counter>>,
histograms: Mutex<HashMap<String, prometheus::Histogram>>,
counter_vecs: Mutex<HashMap<String, CounterVec>>,
histogram_vecs: Mutex<HashMap<String, HistogramVec>>,
gauges: Mutex<HashMap<String, Gauge>>,
}
impl MetricsRegistry {
pub fn new() -> Self {
Self {
inner: Arc::new(MetricsRegistryInner {
registry: Registry::new(),
counters: Mutex::new(HashMap::new()),
histograms: Mutex::new(HashMap::new()),
counter_vecs: Mutex::new(HashMap::new()),
histogram_vecs: Mutex::new(HashMap::new()),
gauges: Mutex::new(HashMap::new()),
}),
}
}
pub fn registry(&self) -> &Registry {
&self.inner.registry
}
pub fn register_counter(
&self,
name: &str,
help: &str,
) -> prometheus::Result<prometheus::Counter> {
let mut counters =
self.inner.counters.lock().expect("metrics mutex poisoned");
if let Some(counter) = counters.get(name) {
return Ok(counter.clone());
}
let counter = prometheus::Counter::new(name, help)?;
self.inner.registry.register(Box::new(counter.clone()))?;
counters.insert(name.to_string(), counter.clone());
Ok(counter)
}
pub fn register_histogram(
&self,
name: &str,
help: &str,
buckets: Vec<f64>,
) -> prometheus::Result<prometheus::Histogram> {
let mut histograms = self
.inner
.histograms
.lock()
.expect("metrics mutex poisoned");
if let Some(histogram) = histograms.get(name) {
return Ok(histogram.clone());
}
let opts = prometheus::HistogramOpts::new(name, help).buckets(buckets);
let histogram = prometheus::Histogram::with_opts(opts)?;
self.inner.registry.register(Box::new(histogram.clone()))?;
histograms.insert(name.to_string(), histogram.clone());
Ok(histogram)
}
pub fn register_counter_vec(
&self,
name: &str,
help: &str,
labels: &[&str],
) -> prometheus::Result<CounterVec> {
let mut counter_vecs = self
.inner
.counter_vecs
.lock()
.expect("metrics mutex poisoned");
if let Some(cv) = counter_vecs.get(name) {
return Ok(cv.clone());
}
let opts = Opts::new(name, help);
let cv = CounterVec::new(opts, labels)?;
self.inner.registry.register(Box::new(cv.clone()))?;
counter_vecs.insert(name.to_string(), cv.clone());
Ok(cv)
}
pub fn register_histogram_vec(
&self,
name: &str,
help: &str,
labels: &[&str],
buckets: Vec<f64>,
) -> prometheus::Result<HistogramVec> {
let mut histogram_vecs = self
.inner
.histogram_vecs
.lock()
.expect("metrics mutex poisoned");
if let Some(hv) = histogram_vecs.get(name) {
return Ok(hv.clone());
}
let opts = HistogramOpts::new(name, help).buckets(buckets);
let hv = HistogramVec::new(opts, labels)?;
self.inner.registry.register(Box::new(hv.clone()))?;
histogram_vecs.insert(name.to_string(), hv.clone());
Ok(hv)
}
pub fn register_gauge(
&self,
name: &str,
help: &str,
) -> prometheus::Result<prometheus::Gauge> {
let mut gauges =
self.inner.gauges.lock().expect("metrics mutex poisoned");
if let Some(gauge) = gauges.get(name) {
return Ok(gauge.clone());
}
let gauge = prometheus::Gauge::new(name, help)?;
self.inner.registry.register(Box::new(gauge.clone()))?;
gauges.insert(name.to_string(), gauge.clone());
Ok(gauge)
}
pub fn encode(&self) -> Result<String, String> {
let mut buffer = Vec::new();
let encoder = TextEncoder::new();
encoder
.encode(&self.inner.registry.gather(), &mut buffer)
.map_err(|e| format!("failed to encode metrics: {e}"))?;
String::from_utf8(buffer).map_err(|e| format!("invalid utf8: {e}"))
}
pub fn gather(&self) -> Vec<prometheus::proto::MetricFamily> {
self.inner.registry.gather()
}
}
impl Default for MetricsRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn record_otel_counter(
name: &'static str,
value: u64,
labels: &[(&'static str, String)],
) {
if value == 0 {
return;
}
let attrs: Vec<KeyValue> = labels
.iter()
.map(|(key, value)| KeyValue::new(*key, value.clone()))
.collect();
static COUNTERS: std::sync::OnceLock<
std::sync::Mutex<
HashMap<&'static str, opentelemetry::metrics::Counter<u64>>,
>,
> = std::sync::OnceLock::new();
let counters =
COUNTERS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut map = counters.lock().expect("otel counter mutex poisoned");
let counter = map
.entry(name)
.or_insert_with(|| {
opentelemetry::global::meter("gitdataai")
.u64_counter(name)
.build()
})
.clone();
drop(map);
counter.add(value, &attrs);
}
#[cfg(test)]
mod tests {
use super::MetricsRegistry;
#[test]
fn repeated_counter_vec_registration_reuses_metric() {
let registry = MetricsRegistry::new();
let first = registry
.register_counter_vec("test_events_total", "Test events", &["kind"])
.expect("first registration should succeed");
let second = registry
.register_counter_vec("test_events_total", "Test events", &["kind"])
.expect("second registration should reuse existing metric");
first.with_label_values(&["a"]).inc();
second.with_label_values(&["a"]).inc();
let encoded = registry.encode().expect("metrics should encode");
assert!(encoded.contains("test_events_total{kind=\"a\"} 2"));
}
#[test]
fn repeated_histogram_vec_registration_reuses_metric() {
let registry = MetricsRegistry::new();
let first = registry
.register_histogram_vec(
"test_duration_seconds",
"Test duration",
&["kind"],
vec![0.1, 1.0],
)
.expect("first registration should succeed");
let second = registry
.register_histogram_vec(
"test_duration_seconds",
"Test duration",
&["kind"],
vec![0.1, 1.0],
)
.expect("second registration should reuse existing metric");
first.with_label_values(&["a"]).observe(0.2);
second.with_label_values(&["a"]).observe(0.3);
let encoded = registry.encode().expect("metrics should encode");
assert!(encoded.contains("test_duration_seconds_count{kind=\"a\"} 2"));
}
}

430
lib/track/otel.rs Normal file
View File

@ -0,0 +1,430 @@
use std::collections::HashMap;
use std::io;
use std::time::Duration;
use anyhow::{Context, bail};
use config::AppConfig;
use opentelemetry::KeyValue;
use opentelemetry::trace::TracerProvider;
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
use opentelemetry_sdk::{
Resource, logs::SdkLoggerProvider, metrics::SdkMeterProvider,
trace::SdkTracerProvider,
};
use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt};
const OTEL_EXPORT_TIMEOUT: Duration = Duration::from_secs(10);
type WorkerGuard = tracing_appender::non_blocking::WorkerGuard;
/// `io::Write` adapter that strips the path prefix down to `gitdataai/` for
/// cleaner log output.
#[derive(Clone)]
struct StripWriter<W: io::Write> {
inner: W,
}
impl<W: io::Write> StripWriter<W> {
fn new(inner: W) -> Self {
Self { inner }
}
}
impl<W: io::Write> io::Write for StripWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let s = String::from_utf8_lossy(buf);
if let Some(pos) = s.rfind("gitdataai/") {
let start = pos + "gitdataai/".len();
self.inner.write_all(s[start..].as_bytes())?;
} else {
self.inner.write_all(buf)?;
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
fn strip_writer() -> impl for<'a> tracing_subscriber::fmt::MakeWriter<'a> {
|| StripWriter::new(io::stdout())
}
pub struct LgtmGuard {
tracer_provider: SdkTracerProvider,
logger_provider: SdkLoggerProvider,
meter_provider: SdkMeterProvider,
_file_guard: Option<WorkerGuard>,
}
struct OtlpEndpoints {
traces: String,
logs: String,
metrics: String,
}
impl Drop for LgtmGuard {
fn drop(&mut self) {
let mut had_error = false;
if let Err(err) = self.tracer_provider.shutdown() {
had_error = true;
eprintln!("failed to shutdown OTel tracer provider: {err}");
}
if let Err(err) = self.meter_provider.shutdown() {
had_error = true;
eprintln!("failed to shutdown OTel meter provider: {err}");
}
if let Err(err) = self.logger_provider.shutdown() {
had_error = true;
eprintln!("failed to shutdown OTel logger provider: {err}");
}
drop(self._file_guard.take());
if !had_error {
eprintln!("OpenTelemetry providers shut down");
}
}
}
pub fn init_lgtm(config: &AppConfig) -> anyhow::Result<Option<LgtmGuard>> {
let filter = EnvFilter::try_new(config.log_level()?)?;
let log_format = config.log_format()?;
if !config.otel_enabled()? {
let file_guard = init_fmt_subscriber(filter, &log_format, config)?;
if let Some(guard) = file_guard {
static FILE_GUARD: std::sync::OnceLock<WorkerGuard> =
std::sync::OnceLock::new();
let _ = FILE_GUARD.set(guard);
}
return Ok(None);
}
let (endpoint, tracer_provider, logger_provider, meter_provider) =
build_lgtm_guard(config)?;
let guard = LgtmGuard {
tracer_provider: tracer_provider.clone(),
logger_provider: logger_provider.clone(),
meter_provider: meter_provider.clone(),
_file_guard: None,
};
let file_guard =
install_otel_subscriber(filter, &guard, &log_format, config)?;
opentelemetry::global::set_meter_provider(meter_provider.clone());
tracing::info!(endpoint = %endpoint, "LGTM observability initialized");
Ok(Some(LgtmGuard {
tracer_provider,
logger_provider,
meter_provider,
_file_guard: file_guard,
}))
}
fn build_lgtm_guard(
config: &AppConfig,
) -> anyhow::Result<(
String,
SdkTracerProvider,
SdkLoggerProvider,
SdkMeterProvider,
)> {
let endpoint = config.otel_endpoint()?;
let endpoints = build_otlp_endpoints(&endpoint)?;
let headers = build_headers(config)?;
let resource = build_resource(config)?;
let trace_exporter = build_trace_exporter(&endpoints.traces, &headers)?;
let log_exporter = build_log_exporter(&endpoints.logs, &headers)?;
let metric_exporter = build_metric_exporter(&endpoints.metrics, headers)?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource.clone())
.build();
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource.clone())
.build();
let meter_provider = SdkMeterProvider::builder()
.with_periodic_exporter(metric_exporter)
.with_resource(resource)
.build();
Ok((endpoint, tracer_provider, logger_provider, meter_provider))
}
fn install_otel_subscriber(
filter: EnvFilter,
guard: &LgtmGuard,
log_format: &str,
config: &AppConfig,
) -> anyhow::Result<Option<WorkerGuard>> {
let tracer = guard.tracer_provider.tracer(env!("CARGO_PKG_NAME"));
let otel_log_layer =
opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(
&guard.logger_provider,
);
if config.log_file_enabled()? {
let dir = config
.log_file_path()
.unwrap_or_else(|_| "./logs".to_string());
let file_appender =
tracing_appender::rolling::daily(dir, "gitdataai.log");
let (non_blocking, file_guard) =
tracing_appender::non_blocking(file_appender);
let file_writer = {
let nb = non_blocking;
move || StripWriter::new(nb.clone())
};
if log_format.eq_ignore_ascii_case("json") {
let subscriber = Registry::default()
.with(filter)
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(otel_log_layer)
.with(
tracing_subscriber::fmt::layer()
.json()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer()),
)
.with(
tracing_subscriber::fmt::layer()
.json()
.with_ansi(false)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(file_writer),
);
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(Some(file_guard));
}
let subscriber = Registry::default()
.with(filter)
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(otel_log_layer)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer()),
)
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(file_writer),
);
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(Some(file_guard));
}
if log_format.eq_ignore_ascii_case("json") {
let subscriber = Registry::default()
.with(filter)
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(otel_log_layer)
.with(
tracing_subscriber::fmt::layer()
.json()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer()),
);
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(None);
}
let subscriber = Registry::default()
.with(filter)
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(otel_log_layer)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer()),
);
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
Ok(None)
}
fn build_trace_exporter(
endpoint: &str,
headers: &HashMap<String, String>,
) -> anyhow::Result<opentelemetry_otlp::SpanExporter> {
opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(endpoint)
.with_timeout(OTEL_EXPORT_TIMEOUT)
.with_headers(headers.clone())
.build()
.context("failed to build OTLP trace exporter")
}
fn build_log_exporter(
endpoint: &str,
headers: &HashMap<String, String>,
) -> anyhow::Result<opentelemetry_otlp::LogExporter> {
opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_endpoint(endpoint)
.with_timeout(OTEL_EXPORT_TIMEOUT)
.with_headers(headers.clone())
.build()
.context("failed to build OTLP log exporter")
}
fn build_metric_exporter(
endpoint: &str,
headers: HashMap<String, String>,
) -> anyhow::Result<opentelemetry_otlp::MetricExporter> {
opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_endpoint(endpoint)
.with_timeout(OTEL_EXPORT_TIMEOUT)
.with_headers(headers)
.build()
.context("failed to build OTLP metric exporter")
}
fn init_fmt_subscriber(
filter: EnvFilter,
log_format: &str,
config: &AppConfig,
) -> anyhow::Result<Option<WorkerGuard>> {
let file_enabled = config.log_file_enabled()?;
if log_format.eq_ignore_ascii_case("json") {
if file_enabled {
let dir = config
.log_file_path()
.unwrap_or_else(|_| "./logs".to_string());
let file_appender =
tracing_appender::rolling::daily(dir, "gitdataai.log");
let (non_blocking, guard) =
tracing_appender::non_blocking(file_appender);
let subscriber = tracing_subscriber::fmt()
.json()
.with_env_filter(filter)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer({
let nb = non_blocking;
move || StripWriter::new(nb.clone())
})
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(Some(guard));
}
let subscriber = tracing_subscriber::fmt()
.json()
.with_env_filter(filter)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer())
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(None);
}
if file_enabled {
let dir = config
.log_file_path()
.unwrap_or_else(|_| "./logs".to_string());
let file_appender =
tracing_appender::rolling::daily(dir, "gitdataai.log");
let (non_blocking, guard) =
tracing_appender::non_blocking(file_appender);
let subscriber = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer({
let nb = non_blocking;
move || StripWriter::new(nb.clone())
})
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(Some(guard));
}
let subscriber = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer())
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
Ok(None)
}
fn build_resource(config: &AppConfig) -> anyhow::Result<Resource> {
Ok(Resource::builder()
.with_service_name(config.otel_service_name()?)
.with_attributes([KeyValue::new(
"service.version",
config.otel_service_version()?,
)])
.build())
}
fn build_headers(
config: &AppConfig,
) -> anyhow::Result<HashMap<String, String>> {
let mut headers = HashMap::new();
if let Some(auth) = config.otel_authorization()? {
headers.insert("Authorization".to_string(), auth);
} else if let Some(token) = config.otel_organization()? {
headers.insert(
"signoz-access-token".to_string(),
format!("ingest:{token}"),
);
}
Ok(headers)
}
fn build_otlp_endpoints(endpoint: &str) -> anyhow::Result<OtlpEndpoints> {
let base = normalize_otlp_base(endpoint)?;
Ok(OtlpEndpoints {
traces: format!("{base}/v1/traces"),
logs: format!("{base}/v1/logs"),
metrics: format!("{base}/v1/metrics"),
})
}
fn normalize_otlp_base(endpoint: &str) -> anyhow::Result<String> {
let endpoint = endpoint.trim().trim_end_matches('/');
if endpoint.is_empty() {
bail!("APP_OTEL_ENDPOINT must not be empty");
}
for suffix in ["/v1/traces", "/v1/logs", "/v1/metrics"] {
if let Some(base) = endpoint.strip_suffix(suffix) {
let base = base.trim_end_matches('/');
if base.is_empty() {
bail!("APP_OTEL_ENDPOINT base URL must not be empty");
}
return Ok(base.to_string());
}
}
Ok(endpoint.to_string())
}