@@ -10,7 +10,6 @@ import (
1010 "time"
1111
1212 "github.com/RandomCodeSpace/otelcontext/internal/storage"
13- "gorm.io/gorm"
1413)
1514
1615// investigationCooldown suppresses repeated PersistInvestigation calls with
@@ -43,13 +42,14 @@ func (c *investigationCooldown) allow(key string, now time.Time) bool {
4342}
4443
4544// cooldownKey builds a case- and whitespace-insensitive key from the tuple
46- // (trigger_service, root_service, root_operation). Service names emitted
47- // from different instrumentations occasionally differ in casing or have
48- // trailing whitespace; canonicalizing here prevents those variants from
49- // bypassing the cooldown guard.
50- func cooldownKey (triggerService , rootService , rootOperation string ) string {
45+ // (tenant, trigger_service, root_service, root_operation). Tenant scopes the
46+ // guard so an error in tenant-A doesn't suppress the same error pattern in
47+ // tenant-B. Service names emitted from different instrumentations occasionally
48+ // differ in casing or have trailing whitespace; canonicalizing here prevents
49+ // those variants from bypassing the cooldown guard.
50+ func cooldownKey (tenant , triggerService , rootService , rootOperation string ) string {
5151 norm := func (s string ) string { return strings .ToLower (strings .TrimSpace (s )) }
52- return norm (triggerService ) + "|" + norm (rootService ) + "|" + norm (rootOperation )
52+ return norm (tenant ) + "|" + norm ( triggerService ) + "|" + norm (rootService ) + "|" + norm (rootOperation )
5353}
5454
5555// prune drops entries older than cutoff to bound map size. Called from
@@ -65,9 +65,14 @@ func (c *investigationCooldown) prune(cutoff time.Time) {
6565}
6666
6767// Investigation is a persisted record of an automated error investigation.
68+ //
69+ // TenantID scopes the row to its originating tenant. The composite
70+ // (tenant_id, created_at) index supports the recency-ordered "investigations
71+ // for tenant X" query that GetInvestigations runs on every read.
6872type Investigation struct {
73+ TenantID string `gorm:"size:64;default:'default';not null;index:idx_investigations_tenant_created,priority:1" json:"tenant_id"`
6974 ID string `gorm:"primaryKey;size:64" json:"id"`
70- CreatedAt time.Time `json:"created_at"`
75+ CreatedAt time.Time `gorm:"index:idx_investigations_tenant_created,priority:2" json:"created_at"`
7176 Status string `gorm:"size:20" json:"status"` // detected, triaged, resolved
7277 Severity string `gorm:"size:20" json:"severity"` // critical, warning, info
7378 TriggerService string `gorm:"size:255;index" json:"trigger_service"`
@@ -88,20 +93,17 @@ func (Investigation) TableName() string {
8893 return "investigations"
8994}
9095
91- // AutoMigrateGraphRAG runs GORM auto-migration for GraphRAG models.
92- func AutoMigrateGraphRAG (db * gorm.DB ) error {
93- return db .AutoMigrate (& Investigation {}, & GraphSnapshot {}, & DrainTemplateRow {})
94- }
95-
9696// PersistInvestigation saves an investigation record from an error chain
9797// analysis. Tenant is accepted explicitly so the caller (the per-tenant
98- // anomaly loop) can re-enter ImpactAnalysis on the correct tenant slice. The
99- // `investigations` table itself does not yet carry a tenant_id column —
100- // Subtask B (RAN-38) owns that migration.
98+ // anomaly loop) can re-enter ImpactAnalysis on the correct tenant slice and so
99+ // the persisted row carries its originating tenant_id.
101100func (g * GraphRAG ) PersistInvestigation (tenant , triggerService string , chains []ErrorChainResult , anomalies []* AnomalyNode ) {
102101 if len (chains ) == 0 {
103102 return
104103 }
104+ if tenant == "" {
105+ tenant = storage .DefaultTenantID
106+ }
105107
106108 firstChain := chains [0 ]
107109 if firstChain .RootCause == nil {
@@ -111,10 +113,12 @@ func (g *GraphRAG) PersistInvestigation(tenant, triggerService string, chains []
111113 now := time .Now ()
112114
113115 // Cooldown: suppress repeat investigations for the same
114- // (trigger_service, root_service, root_operation) inside a sliding window.
115- // Keys are canonicalized (lower + trim) so "Orders" and "orders " share a
116- // bucket — otherwise trivial casing differences would bypass the guard.
117- key := cooldownKey (triggerService , firstChain .RootCause .Service , firstChain .RootCause .Operation )
116+ // (tenant, trigger_service, root_service, root_operation) inside a sliding
117+ // window. Keys are canonicalized (lower + trim) so "Orders" and "orders "
118+ // share a bucket — otherwise trivial casing differences would bypass the
119+ // guard. Tenant scoping prevents an error in one tenant from suppressing
120+ // the same pattern in another.
121+ key := cooldownKey (tenant , triggerService , firstChain .RootCause .Service , firstChain .RootCause .Operation )
118122 if g .invCooldown != nil && ! g .invCooldown .allow (key , now ) {
119123 return
120124 }
@@ -180,6 +184,7 @@ func (g *GraphRAG) PersistInvestigation(tenant, triggerService string, chains []
180184 }
181185
182186 inv := Investigation {
187+ TenantID : tenant ,
183188 ID : id ,
184189 CreatedAt : now ,
185190 Status : "detected" ,
@@ -202,23 +207,30 @@ func (g *GraphRAG) PersistInvestigation(tenant, triggerService string, chains []
202207 return
203208 }
204209 if err := g .repo .DB ().Create (& inv ).Error ; err != nil {
205- slog .Error ("Failed to persist investigation" , "error" , err )
210+ slog .Error ("Failed to persist investigation" , "tenant" , tenant , " error" , err )
206211 return
207212 }
208213
209- slog .Info ("Investigation persisted" , "id" , id , "service" , triggerService , "severity" , severity )
214+ slog .Info ("Investigation persisted" , "id" , id , "tenant" , tenant , " service" , triggerService , "severity" , severity )
210215}
211216
212- // GetInvestigations queries persisted investigations.
213- func (g * GraphRAG ) GetInvestigations (service , severity , status string , limit int ) ([]Investigation , error ) {
217+ // GetInvestigations queries persisted investigations scoped to the tenant
218+ // carried by ctx. The composite (tenant_id, created_at) index supports the
219+ // recency-ordered scan.
220+ func (g * GraphRAG ) GetInvestigations (ctx context.Context , service , severity , status string , limit int ) ([]Investigation , error ) {
214221 if limit <= 0 {
215222 limit = 20
216223 }
217224 if limit > 100 {
218225 limit = 100
219226 }
220227
221- db := g .repo .DB ().Model (& Investigation {}).Order ("created_at DESC" ).Limit (limit )
228+ tenant := storage .TenantFromContext (ctx )
229+ db := g .repo .DB ().
230+ Model (& Investigation {}).
231+ Where ("tenant_id = ?" , tenant ).
232+ Order ("created_at DESC" ).
233+ Limit (limit )
222234 if service != "" {
223235 db = db .Where ("trigger_service = ? OR root_service = ?" , service , service )
224236 }
@@ -236,10 +248,13 @@ func (g *GraphRAG) GetInvestigations(service, severity, status string, limit int
236248 return investigations , nil
237249}
238250
239- // GetInvestigation retrieves a single investigation by ID.
240- func (g * GraphRAG ) GetInvestigation (id string ) (* Investigation , error ) {
251+ // GetInvestigation retrieves a single investigation by ID, scoped to the
252+ // tenant carried by ctx. Returning ErrRecordNotFound for cross-tenant lookups
253+ // prevents id-guessing from leaking another tenant's row.
254+ func (g * GraphRAG ) GetInvestigation (ctx context.Context , id string ) (* Investigation , error ) {
255+ tenant := storage .TenantFromContext (ctx )
241256 var inv Investigation
242- if err := g .repo .DB ().Where ("id = ?" , id ).First (& inv ).Error ; err != nil {
257+ if err := g .repo .DB ().Where ("tenant_id = ? AND id = ?" , tenant , id ).First (& inv ).Error ; err != nil {
243258 return nil , err
244259 }
245260 return & inv , nil
0 commit comments