feat(ai): add real-time anomaly detection endpoint
Add /api/ai/intelligence/anomalies endpoint that compares live metrics against learned baselines to surface deviations - all deterministic (no LLM required). Backend: - Add AnomalyReport struct with severity classification - Add CheckResourceAnomalies method to baseline store - Add HandleGetAnomalies API handler - Add GetStateProvider getter to AI service Frontend: - Add AnomalyReport and AnomaliesResponse types - Add getAnomalies API function - Add AnomalySeverity type This is the first step toward surfacing deterministic intelligence directly in the UI without requiring LLM interaction.
This commit is contained in:
parent
8019acd6b6
commit
c5c9bf4fb9
6 changed files with 398 additions and 9 deletions
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
ChangesResponse,
|
ChangesResponse,
|
||||||
BaselinesResponse,
|
BaselinesResponse,
|
||||||
RemediationsResponse,
|
RemediationsResponse,
|
||||||
|
AnomaliesResponse,
|
||||||
IntelligenceSummary,
|
IntelligenceSummary,
|
||||||
ResourceIntelligence,
|
ResourceIntelligence,
|
||||||
} from '@/types/aiIntelligence';
|
} from '@/types/aiIntelligence';
|
||||||
|
|
@ -121,6 +122,13 @@ export class AIAPI {
|
||||||
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/remediations${query ? `?${query}` : ''}`) as Promise<RemediationsResponse>;
|
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/remediations${query ? `?${query}` : ''}`) as Promise<RemediationsResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current anomalies (real-time baseline deviation detection)
|
||||||
|
// Returns metrics that are currently deviating significantly from learned baselines
|
||||||
|
static async getAnomalies(resourceId?: string): Promise<AnomaliesResponse> {
|
||||||
|
const params = resourceId ? `?resource_id=${encodeURIComponent(resourceId)}` : '';
|
||||||
|
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/anomalies${params}`) as Promise<AnomaliesResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Unified Intelligence API
|
// Unified Intelligence API
|
||||||
// Aggregates all AI subsystems into a single view
|
// Aggregates all AI subsystems into a single view
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,33 @@ export interface RemediationsResponse extends LicenseGatedResponse {
|
||||||
stats?: RemediationStats;
|
stats?: RemediationStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real-time anomaly detection types
|
||||||
|
export type AnomalySeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export interface AnomalyReport {
|
||||||
|
resource_id: string;
|
||||||
|
resource_name: string;
|
||||||
|
resource_type: string;
|
||||||
|
metric: string;
|
||||||
|
current_value: number;
|
||||||
|
baseline_mean: number;
|
||||||
|
baseline_std_dev: number;
|
||||||
|
z_score: number;
|
||||||
|
severity: AnomalySeverity;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnomaliesResponse extends LicenseGatedResponse {
|
||||||
|
anomalies: AnomalyReport[];
|
||||||
|
count: number;
|
||||||
|
severity_counts: {
|
||||||
|
critical: number;
|
||||||
|
high: number;
|
||||||
|
medium: number;
|
||||||
|
low: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Unified Intelligence Types (for /api/ai/intelligence endpoint)
|
// Unified Intelligence Types (for /api/ai/intelligence endpoint)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -231,12 +258,3 @@ export interface ResourceIntelligence {
|
||||||
note_count: number;
|
note_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnomalyReport {
|
|
||||||
metric: string;
|
|
||||||
current_value: number;
|
|
||||||
baseline_mean: number;
|
|
||||||
z_score: number;
|
|
||||||
severity: string; // "critical", "high", "medium", "low"
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,131 @@ func (s *Store) CheckAnomaly(resourceID, metric string, value float64) (AnomalyS
|
||||||
return severity, zScore, baseline
|
return severity, zScore, baseline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AnomalyReport represents a detected anomaly for a single metric
|
||||||
|
type AnomalyReport struct {
|
||||||
|
ResourceID string `json:"resource_id"`
|
||||||
|
ResourceName string `json:"resource_name,omitempty"`
|
||||||
|
ResourceType string `json:"resource_type,omitempty"`
|
||||||
|
Metric string `json:"metric"`
|
||||||
|
CurrentValue float64 `json:"current_value"`
|
||||||
|
BaselineMean float64 `json:"baseline_mean"`
|
||||||
|
BaselineStdDev float64 `json:"baseline_std_dev"`
|
||||||
|
ZScore float64 `json:"z_score"`
|
||||||
|
Severity AnomalySeverity `json:"severity"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckResourceAnomalies checks multiple metrics for a resource and returns all anomalies
|
||||||
|
func (s *Store) CheckResourceAnomalies(resourceID string, metrics map[string]float64) []AnomalyReport {
|
||||||
|
var anomalies []AnomalyReport
|
||||||
|
|
||||||
|
for metric, value := range metrics {
|
||||||
|
severity, zScore, baseline := s.CheckAnomaly(resourceID, metric, value)
|
||||||
|
if severity != AnomalyNone {
|
||||||
|
report := AnomalyReport{
|
||||||
|
ResourceID: resourceID,
|
||||||
|
Metric: metric,
|
||||||
|
CurrentValue: value,
|
||||||
|
ZScore: zScore,
|
||||||
|
Severity: severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
if baseline != nil {
|
||||||
|
report.BaselineMean = baseline.Mean
|
||||||
|
report.BaselineStdDev = baseline.StdDev
|
||||||
|
|
||||||
|
// Generate human-readable description
|
||||||
|
ratio := value / baseline.Mean
|
||||||
|
direction := "above"
|
||||||
|
if zScore < 0 {
|
||||||
|
direction = "below"
|
||||||
|
}
|
||||||
|
report.Description = formatAnomalyDescription(metric, ratio, direction, severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
anomalies = append(anomalies, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAnomalyDescription generates a human-readable anomaly description
|
||||||
|
func formatAnomalyDescription(metric string, ratio float64, direction string, severity AnomalySeverity) string {
|
||||||
|
metricLabel := metric
|
||||||
|
switch metric {
|
||||||
|
case "cpu":
|
||||||
|
metricLabel = "CPU usage"
|
||||||
|
case "memory":
|
||||||
|
metricLabel = "Memory usage"
|
||||||
|
case "disk":
|
||||||
|
metricLabel = "Disk usage"
|
||||||
|
case "network_in":
|
||||||
|
metricLabel = "Network inbound"
|
||||||
|
case "network_out":
|
||||||
|
metricLabel = "Network outbound"
|
||||||
|
}
|
||||||
|
|
||||||
|
severityLabel := ""
|
||||||
|
switch severity {
|
||||||
|
case AnomalyCritical:
|
||||||
|
severityLabel = "Critical anomaly: "
|
||||||
|
case AnomalyHigh:
|
||||||
|
severityLabel = "High anomaly: "
|
||||||
|
case AnomalyMedium:
|
||||||
|
severityLabel = "Moderate anomaly: "
|
||||||
|
case AnomalyLow:
|
||||||
|
severityLabel = "Minor anomaly: "
|
||||||
|
}
|
||||||
|
|
||||||
|
return severityLabel + metricLabel + " is " + formatRatio(ratio) + " " + direction + " normal baseline"
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRatio formats a ratio for display (e.g., 2.5 -> "2.5x")
|
||||||
|
func formatRatio(ratio float64) string {
|
||||||
|
if ratio < 0.01 {
|
||||||
|
return "near zero"
|
||||||
|
}
|
||||||
|
if ratio < 1 {
|
||||||
|
return "significantly below"
|
||||||
|
}
|
||||||
|
if ratio < 1.5 {
|
||||||
|
return "slightly above"
|
||||||
|
}
|
||||||
|
if ratio < 2 {
|
||||||
|
return "1.5x"
|
||||||
|
}
|
||||||
|
if ratio < 3 {
|
||||||
|
return "2x"
|
||||||
|
}
|
||||||
|
if ratio < 5 {
|
||||||
|
return "3x"
|
||||||
|
}
|
||||||
|
return "~" + string([]byte{byte('0' + int(ratio))}) + "x"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllAnomalies checks all resources with current metrics and returns all anomalies
|
||||||
|
// metricsProvider is a function that returns current metrics for a resource ID
|
||||||
|
func (s *Store) GetAllAnomalies(metricsProvider func(resourceID string) map[string]float64) []AnomalyReport {
|
||||||
|
s.mu.RLock()
|
||||||
|
resourceIDs := make([]string, 0, len(s.baselines))
|
||||||
|
for id := range s.baselines {
|
||||||
|
resourceIDs = append(resourceIDs, id)
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
var allAnomalies []AnomalyReport
|
||||||
|
for _, resourceID := range resourceIDs {
|
||||||
|
metrics := metricsProvider(resourceID)
|
||||||
|
if len(metrics) > 0 {
|
||||||
|
anomalies := s.CheckResourceAnomalies(resourceID, metrics)
|
||||||
|
allAnomalies = append(allAnomalies, anomalies...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allAnomalies
|
||||||
|
}
|
||||||
|
|
||||||
// ResourceCount returns the number of resources with baselines
|
// ResourceCount returns the number of resources with baselines
|
||||||
func (s *Store) ResourceCount() int {
|
func (s *Store) ResourceCount() int {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,13 @@ func (s *Service) SetStateProvider(sp StateProvider) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStateProvider returns the state provider for infrastructure context
|
||||||
|
func (s *Service) GetStateProvider() StateProvider {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.stateProvider
|
||||||
|
}
|
||||||
|
|
||||||
// GetPatrolService returns the patrol service for background monitoring
|
// GetPatrolService returns the patrol service for background monitoring
|
||||||
func (s *Service) GetPatrolService() *PatrolService {
|
func (s *Service) GetPatrolService() *PatrolService {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -529,3 +529,233 @@ func remediationStatsFromRecords(records []ai.RemediationRecord) map[string]int
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleGetAnomalies returns current baseline anomalies (GET /api/ai/intelligence/anomalies)
|
||||||
|
// This compares live metrics against learned baselines to surface deviations.
|
||||||
|
// Anomalies are deterministic (no LLM) - based on statistical z-score thresholds.
|
||||||
|
func (h *AISettingsHandler) HandleGetAnomalies(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patrol := h.aiService.GetPatrolService()
|
||||||
|
if patrol == nil {
|
||||||
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
|
"anomalies": []interface{}{},
|
||||||
|
"message": "Patrol service not initialized",
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to write anomalies response")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baselineStore := patrol.GetBaselineStore()
|
||||||
|
if baselineStore == nil {
|
||||||
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
|
"anomalies": []interface{}{},
|
||||||
|
"message": "Baseline store not initialized - baselines are still learning",
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to write anomalies response")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current metrics from state provider
|
||||||
|
stateProvider := h.aiService.GetStateProvider()
|
||||||
|
if stateProvider == nil {
|
||||||
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
|
"anomalies": []interface{}{},
|
||||||
|
"message": "State provider not available",
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to write anomalies response")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get resource filter if provided
|
||||||
|
resourceID := r.URL.Query().Get("resource_id")
|
||||||
|
|
||||||
|
// Collect anomalies
|
||||||
|
var result []map[string]interface{}
|
||||||
|
|
||||||
|
// Get all baselines and check current metrics
|
||||||
|
allBaselines := baselineStore.GetAllBaselines()
|
||||||
|
|
||||||
|
// Group by resource ID
|
||||||
|
resourceMetrics := make(map[string]map[string]float64)
|
||||||
|
resourceInfo := make(map[string]struct{ name, rtype string })
|
||||||
|
|
||||||
|
for _, baseline := range allBaselines {
|
||||||
|
if resourceID != "" && baseline.ResourceID != resourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := resourceMetrics[baseline.ResourceID]; !ok {
|
||||||
|
resourceMetrics[baseline.ResourceID] = make(map[string]float64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current state to extract live metrics
|
||||||
|
state := stateProvider.GetState()
|
||||||
|
|
||||||
|
// Check VMs
|
||||||
|
for _, vm := range state.VMs {
|
||||||
|
if vm.Template {
|
||||||
|
continue // Skip templates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if we don't have baselines for this resource
|
||||||
|
if _, ok := resourceMetrics[vm.ID]; !ok {
|
||||||
|
if resourceID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vm.ID != resourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := map[string]float64{
|
||||||
|
"cpu": vm.CPU * 100, // CPU is already 0-1, convert to percentage
|
||||||
|
"memory": vm.Memory.Usage, // Memory.Usage is already in percentage
|
||||||
|
}
|
||||||
|
if vm.Disk.Usage > 0 {
|
||||||
|
metrics["disk"] = vm.Disk.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
anomalies := baselineStore.CheckResourceAnomalies(vm.ID, metrics)
|
||||||
|
for _, anomaly := range anomalies {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"resource_id": anomaly.ResourceID,
|
||||||
|
"resource_name": vm.Name,
|
||||||
|
"resource_type": "vm",
|
||||||
|
"metric": anomaly.Metric,
|
||||||
|
"current_value": anomaly.CurrentValue,
|
||||||
|
"baseline_mean": anomaly.BaselineMean,
|
||||||
|
"baseline_std_dev": anomaly.BaselineStdDev,
|
||||||
|
"z_score": anomaly.ZScore,
|
||||||
|
"severity": anomaly.Severity,
|
||||||
|
"description": anomaly.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store info for any additional processing
|
||||||
|
resourceInfo[vm.ID] = struct{ name, rtype string }{vm.Name, "vm"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Containers
|
||||||
|
for _, ct := range state.Containers {
|
||||||
|
if ct.Template {
|
||||||
|
continue // Skip templates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if we don't have baselines for this resource
|
||||||
|
if _, ok := resourceMetrics[ct.ID]; !ok {
|
||||||
|
if resourceID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ct.ID != resourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := map[string]float64{
|
||||||
|
"cpu": ct.CPU * 100, // CPU is already 0-1, convert to percentage
|
||||||
|
"memory": ct.Memory.Usage, // Memory.Usage is already in percentage
|
||||||
|
}
|
||||||
|
if ct.Disk.Usage > 0 {
|
||||||
|
metrics["disk"] = ct.Disk.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
anomalies := baselineStore.CheckResourceAnomalies(ct.ID, metrics)
|
||||||
|
for _, anomaly := range anomalies {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"resource_id": anomaly.ResourceID,
|
||||||
|
"resource_name": ct.Name,
|
||||||
|
"resource_type": "container",
|
||||||
|
"metric": anomaly.Metric,
|
||||||
|
"current_value": anomaly.CurrentValue,
|
||||||
|
"baseline_mean": anomaly.BaselineMean,
|
||||||
|
"baseline_std_dev": anomaly.BaselineStdDev,
|
||||||
|
"z_score": anomaly.ZScore,
|
||||||
|
"severity": anomaly.Severity,
|
||||||
|
"description": anomaly.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store info for any additional processing
|
||||||
|
resourceInfo[ct.ID] = struct{ name, rtype string }{ct.Name, "container"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check nodes
|
||||||
|
for _, node := range state.Nodes {
|
||||||
|
nodeID := node.ID
|
||||||
|
|
||||||
|
// Skip if we don't have baselines for this resource
|
||||||
|
if _, ok := resourceMetrics[nodeID]; !ok {
|
||||||
|
if resourceID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nodeID != resourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := map[string]float64{
|
||||||
|
"cpu": node.CPU * 100, // CPU is already 0-1, convert to percentage
|
||||||
|
"memory": node.Memory.Usage, // Memory.Usage is already in percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
anomalies := baselineStore.CheckResourceAnomalies(nodeID, metrics)
|
||||||
|
for _, anomaly := range anomalies {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"resource_id": anomaly.ResourceID,
|
||||||
|
"resource_name": node.Name,
|
||||||
|
"resource_type": "node",
|
||||||
|
"metric": anomaly.Metric,
|
||||||
|
"current_value": anomaly.CurrentValue,
|
||||||
|
"baseline_mean": anomaly.BaselineMean,
|
||||||
|
"baseline_std_dev": anomaly.BaselineStdDev,
|
||||||
|
"z_score": anomaly.ZScore,
|
||||||
|
"severity": anomaly.Severity,
|
||||||
|
"description": anomaly.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// License gating
|
||||||
|
locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol)
|
||||||
|
if locked {
|
||||||
|
w.Header().Set("X-License-Required", "true")
|
||||||
|
w.Header().Set("X-License-Feature", license.FeatureAIPatrol)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := len(result)
|
||||||
|
|
||||||
|
// Count by severity for summary
|
||||||
|
severityCounts := map[string]int{
|
||||||
|
"critical": 0,
|
||||||
|
"high": 0,
|
||||||
|
"medium": 0,
|
||||||
|
"low": 0,
|
||||||
|
}
|
||||||
|
for _, anomaly := range result {
|
||||||
|
if sev, ok := anomaly["severity"].(string); ok {
|
||||||
|
severityCounts[sev]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if locked {
|
||||||
|
result = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||||
|
"anomalies": result,
|
||||||
|
"count": count,
|
||||||
|
"severity_counts": severityCounts,
|
||||||
|
"license_required": locked,
|
||||||
|
"upgrade_url": aiIntelligenceUpgradeURL,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to write anomalies response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1216,6 +1216,7 @@ func (r *Router) setupRoutes() {
|
||||||
r.mux.HandleFunc("/api/ai/intelligence/changes", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRecentChanges))
|
r.mux.HandleFunc("/api/ai/intelligence/changes", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRecentChanges))
|
||||||
r.mux.HandleFunc("/api/ai/intelligence/baselines", RequireAuth(r.config, r.aiSettingsHandler.HandleGetBaselines))
|
r.mux.HandleFunc("/api/ai/intelligence/baselines", RequireAuth(r.config, r.aiSettingsHandler.HandleGetBaselines))
|
||||||
r.mux.HandleFunc("/api/ai/intelligence/remediations", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRemediations))
|
r.mux.HandleFunc("/api/ai/intelligence/remediations", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRemediations))
|
||||||
|
r.mux.HandleFunc("/api/ai/intelligence/anomalies", RequireAuth(r.config, r.aiSettingsHandler.HandleGetAnomalies))
|
||||||
|
|
||||||
// Agent WebSocket for AI command execution
|
// Agent WebSocket for AI command execution
|
||||||
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
|
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue