mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
✨ feat(auth): add error logs API and database schema
Add centralized error logging endpoint to mana-core-auth: - Error logs database schema with app_id, error message, stack traces - POST /error-logs endpoint for single errors - POST /error-logs/batch endpoint for batch submissions - Error logs service with automatic cleanup of old entries - DTOs with validation for error log submissions
This commit is contained in:
parent
5e1118b711
commit
319ccd1a46
11 changed files with 527 additions and 0 deletions
|
|
@ -6,6 +6,7 @@ import configuration from './config/configuration';
|
|||
import { AuthModule } from './auth/auth.module';
|
||||
import { CreditsModule } from './credits/credits.module';
|
||||
import { EmailModule } from './email/email.module';
|
||||
import { ErrorLogsModule } from './error-logs/error-logs.module';
|
||||
import { FeedbackModule } from './feedback/feedback.module';
|
||||
import { ReferralsModule } from './referrals/referrals.module';
|
||||
import { SecurityModule } from './security/security.module';
|
||||
|
|
@ -31,6 +32,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||
AuthModule,
|
||||
CreditsModule,
|
||||
EmailModule,
|
||||
ErrorLogsModule,
|
||||
FeedbackModule,
|
||||
HealthModule,
|
||||
ReferralsModule,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
-- Add error_logs schema and table for centralized error tracking
|
||||
-- This migration is safe to run on existing databases
|
||||
|
||||
-- Create error_logs schema if not exists
|
||||
CREATE SCHEMA IF NOT EXISTS "error_logs";
|
||||
|
||||
-- Create enum types if not exist (PostgreSQL 9.1+ required for IF NOT EXISTS)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "error_source_type" AS ENUM('backend', 'frontend_web', 'frontend_mobile');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "error_environment" AS ENUM('development', 'staging', 'production');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "error_severity" AS ENUM('debug', 'info', 'warning', 'error', 'critical');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Create error_logs table
|
||||
CREATE TABLE IF NOT EXISTS "error_logs"."error_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"error_code" text NOT NULL,
|
||||
"error_type" text NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"stack_trace" text,
|
||||
"app_id" text NOT NULL,
|
||||
"source_type" "error_source_type",
|
||||
"service_name" text,
|
||||
"user_id" text,
|
||||
"session_id" text,
|
||||
"request_url" text,
|
||||
"request_method" text,
|
||||
"request_headers" jsonb,
|
||||
"request_body" jsonb,
|
||||
"response_status_code" integer,
|
||||
"environment" "error_environment",
|
||||
"severity" "error_severity" DEFAULT 'error',
|
||||
"context" jsonb DEFAULT '{}'::jsonb,
|
||||
"fingerprint" text,
|
||||
"user_agent" text,
|
||||
"browser_info" jsonb,
|
||||
"device_info" jsonb,
|
||||
"occurred_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Add foreign key constraint (safe - ignores if exists)
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "error_logs"."error_logs"
|
||||
ADD CONSTRAINT "error_logs_user_id_users_id_fk"
|
||||
FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id")
|
||||
ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Create indexes (safe - ignores if exists)
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_app_id_idx" ON "error_logs"."error_logs" USING btree ("app_id");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_user_id_idx" ON "error_logs"."error_logs" USING btree ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_environment_idx" ON "error_logs"."error_logs" USING btree ("environment");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_severity_idx" ON "error_logs"."error_logs" USING btree ("severity");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_occurred_at_idx" ON "error_logs"."error_logs" USING btree ("occurred_at");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_error_code_idx" ON "error_logs"."error_logs" USING btree ("error_code");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_fingerprint_idx" ON "error_logs"."error_logs" USING btree ("fingerprint");
|
||||
|
|
@ -22,6 +22,13 @@
|
|||
"when": 1734560000000,
|
||||
"tag": "0002_fix_session_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1734600000000,
|
||||
"tag": "0003_add_error_logs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
97
services/mana-core-auth/src/db/schema/error-logs.schema.ts
Normal file
97
services/mana-core-auth/src/db/schema/error-logs.schema.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
pgSchema,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
integer,
|
||||
index,
|
||||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth.schema';
|
||||
|
||||
export const errorLogsSchema = pgSchema('error_logs');
|
||||
|
||||
// Source type enum
|
||||
export const errorSourceTypeEnum = pgEnum('error_source_type', [
|
||||
'backend',
|
||||
'frontend_web',
|
||||
'frontend_mobile',
|
||||
]);
|
||||
|
||||
// Environment enum
|
||||
export const errorEnvironmentEnum = pgEnum('error_environment', [
|
||||
'development',
|
||||
'staging',
|
||||
'production',
|
||||
]);
|
||||
|
||||
// Severity enum
|
||||
export const errorSeverityEnum = pgEnum('error_severity', [
|
||||
'debug',
|
||||
'info',
|
||||
'warning',
|
||||
'error',
|
||||
'critical',
|
||||
]);
|
||||
|
||||
// Error logs table
|
||||
export const errorLogs = errorLogsSchema.table(
|
||||
'error_logs',
|
||||
{
|
||||
// Primary key
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
// Error identification
|
||||
errorCode: text('error_code').notNull(),
|
||||
errorType: text('error_type').notNull(),
|
||||
message: text('message').notNull(),
|
||||
stackTrace: text('stack_trace'),
|
||||
|
||||
// Source identification
|
||||
appId: text('app_id').notNull(),
|
||||
sourceType: errorSourceTypeEnum('source_type'),
|
||||
serviceName: text('service_name'),
|
||||
|
||||
// User context (optional)
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
sessionId: text('session_id'),
|
||||
|
||||
// Request metadata (backend errors)
|
||||
requestUrl: text('request_url'),
|
||||
requestMethod: text('request_method'),
|
||||
requestHeaders: jsonb('request_headers'),
|
||||
requestBody: jsonb('request_body'),
|
||||
responseStatusCode: integer('response_status_code'),
|
||||
|
||||
// Classification
|
||||
environment: errorEnvironmentEnum('environment'),
|
||||
severity: errorSeverityEnum('severity').default('error'),
|
||||
|
||||
// Additional context
|
||||
context: jsonb('context').default({}),
|
||||
fingerprint: text('fingerprint'),
|
||||
|
||||
// Browser/device info (frontend errors)
|
||||
userAgent: text('user_agent'),
|
||||
browserInfo: jsonb('browser_info'),
|
||||
deviceInfo: jsonb('device_info'),
|
||||
|
||||
// Timestamps
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
appIdIdx: index('error_logs_app_id_idx').on(table.appId),
|
||||
userIdIdx: index('error_logs_user_id_idx').on(table.userId),
|
||||
environmentIdx: index('error_logs_environment_idx').on(table.environment),
|
||||
severityIdx: index('error_logs_severity_idx').on(table.severity),
|
||||
occurredAtIdx: index('error_logs_occurred_at_idx').on(table.occurredAt),
|
||||
errorCodeIdx: index('error_logs_error_code_idx').on(table.errorCode),
|
||||
fingerprintIdx: index('error_logs_fingerprint_idx').on(table.fingerprint),
|
||||
})
|
||||
);
|
||||
|
||||
// Type exports
|
||||
export type ErrorLog = typeof errorLogs.$inferSelect;
|
||||
export type NewErrorLog = typeof errorLogs.$inferInsert;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './auth.schema';
|
||||
export * from './credits.schema';
|
||||
export * from './error-logs.schema';
|
||||
export * from './feedback.schema';
|
||||
export * from './organizations.schema';
|
||||
export * from './referrals.schema';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { Type } from 'class-transformer';
|
||||
import { ValidateNested, IsArray, ArrayMaxSize, ArrayMinSize } from 'class-validator';
|
||||
import { CreateErrorLogDto } from './create-error-log.dto';
|
||||
|
||||
export class BatchErrorLogDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(100)
|
||||
@Type(() => CreateErrorLogDto)
|
||||
errors: CreateErrorLogDto[];
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
MaxLength,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateErrorLogDto {
|
||||
// Required fields
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
errorCode: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
errorType: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
|
||||
// Optional fields
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50000)
|
||||
stackTrace?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
appId?: string;
|
||||
|
||||
@IsEnum(['backend', 'frontend_web', 'frontend_mobile'])
|
||||
@IsOptional()
|
||||
sourceType?: 'backend' | 'frontend_web' | 'frontend_mobile';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
serviceName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
userId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
sessionId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(2000)
|
||||
requestUrl?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(10)
|
||||
requestMethod?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
requestHeaders?: Record<string, unknown>;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
requestBody?: Record<string, unknown>;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(100)
|
||||
@Max(599)
|
||||
responseStatusCode?: number;
|
||||
|
||||
@IsEnum(['development', 'staging', 'production'])
|
||||
@IsOptional()
|
||||
environment?: 'development' | 'staging' | 'production';
|
||||
|
||||
@IsEnum(['debug', 'info', 'warning', 'error', 'critical'])
|
||||
@IsOptional()
|
||||
severity?: 'debug' | 'info' | 'warning' | 'error' | 'critical';
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
context?: Record<string, unknown>;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(256)
|
||||
fingerprint?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
browserInfo?: Record<string, unknown>;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
deviceInfo?: Record<string, unknown>;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
userAgent?: string;
|
||||
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
occurredAt?: string;
|
||||
}
|
||||
2
services/mana-core-auth/src/error-logs/dto/index.ts
Normal file
2
services/mana-core-auth/src/error-logs/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-error-log.dto';
|
||||
export * from './batch-error-log.dto';
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Controller, Post, Body, Headers, UseGuards } from '@nestjs/common';
|
||||
import { ErrorLogsService } from './error-logs.service';
|
||||
import { OptionalAuthGuard } from '../common/guards/optional-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { CreateErrorLogDto, BatchErrorLogDto } from './dto';
|
||||
|
||||
@Controller('api/v1/errors')
|
||||
export class ErrorLogsController {
|
||||
constructor(private readonly errorLogsService: ErrorLogsService) {}
|
||||
|
||||
/**
|
||||
* Create a single error log entry
|
||||
* Authentication is optional - uses user context if available
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
async createErrorLog(
|
||||
@CurrentUser() user: CurrentUserData | null,
|
||||
@Body() dto: CreateErrorLogDto,
|
||||
@Headers('x-app-id') appIdHeader?: string
|
||||
) {
|
||||
return this.errorLogsService.createErrorLog(dto, appIdHeader, user?.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple error log entries in batch
|
||||
* Useful for batch reporting of errors (e.g., on app startup or periodic sync)
|
||||
*/
|
||||
@Post('batch')
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
async createErrorLogsBatch(
|
||||
@CurrentUser() user: CurrentUserData | null,
|
||||
@Body() dto: BatchErrorLogDto,
|
||||
@Headers('x-app-id') appIdHeader?: string
|
||||
) {
|
||||
return this.errorLogsService.createErrorLogsBatch(dto.errors, appIdHeader, user?.userId);
|
||||
}
|
||||
}
|
||||
11
services/mana-core-auth/src/error-logs/error-logs.module.ts
Normal file
11
services/mana-core-auth/src/error-logs/error-logs.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ErrorLogsController } from './error-logs.controller';
|
||||
import { ErrorLogsService } from './error-logs.service';
|
||||
import { OptionalAuthGuard } from '../common/guards/optional-auth.guard';
|
||||
|
||||
@Module({
|
||||
controllers: [ErrorLogsController],
|
||||
providers: [ErrorLogsService, OptionalAuthGuard],
|
||||
exports: [ErrorLogsService],
|
||||
})
|
||||
export class ErrorLogsModule {}
|
||||
171
services/mana-core-auth/src/error-logs/error-logs.service.ts
Normal file
171
services/mana-core-auth/src/error-logs/error-logs.service.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from '../db/connection';
|
||||
import { errorLogs } from '../db/schema';
|
||||
import type { CreateErrorLogDto } from './dto';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// Sensitive header keys to sanitize
|
||||
const SENSITIVE_HEADERS = ['authorization', 'cookie', 'x-api-key', 'api-key'];
|
||||
|
||||
// Sensitive body field keys to sanitize
|
||||
const SENSITIVE_BODY_FIELDS = ['password', 'token', 'secret', 'apikey', 'api_key'];
|
||||
|
||||
@Injectable()
|
||||
export class ErrorLogsService {
|
||||
private readonly logger = new Logger(ErrorLogsService.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single error log entry
|
||||
*/
|
||||
async createErrorLog(
|
||||
dto: CreateErrorLogDto,
|
||||
appIdHeader?: string,
|
||||
userId?: string
|
||||
): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
try {
|
||||
const db = this.getDb();
|
||||
|
||||
const appId = dto.appId || appIdHeader || 'unknown';
|
||||
const sanitizedHeaders = this.sanitizeHeaders(dto.requestHeaders);
|
||||
const sanitizedBody = this.sanitizeBody(dto.requestBody);
|
||||
const fingerprint = dto.fingerprint || this.generateFingerprint(dto, appId);
|
||||
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
|
||||
|
||||
const [errorLog] = await db
|
||||
.insert(errorLogs)
|
||||
.values({
|
||||
errorCode: dto.errorCode,
|
||||
errorType: dto.errorType,
|
||||
message: dto.message,
|
||||
stackTrace: dto.stackTrace,
|
||||
appId,
|
||||
sourceType: dto.sourceType,
|
||||
serviceName: dto.serviceName,
|
||||
userId: dto.userId || userId,
|
||||
sessionId: dto.sessionId,
|
||||
requestUrl: dto.requestUrl,
|
||||
requestMethod: dto.requestMethod,
|
||||
requestHeaders: sanitizedHeaders,
|
||||
requestBody: sanitizedBody,
|
||||
responseStatusCode: dto.responseStatusCode,
|
||||
environment: dto.environment,
|
||||
severity: dto.severity || 'error',
|
||||
context: dto.context || {},
|
||||
fingerprint,
|
||||
userAgent: dto.userAgent,
|
||||
browserInfo: dto.browserInfo,
|
||||
deviceInfo: dto.deviceInfo,
|
||||
occurredAt,
|
||||
})
|
||||
.returning({ id: errorLogs.id });
|
||||
|
||||
return { success: true, id: errorLog.id };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create error log', error);
|
||||
return { success: false, error: 'Failed to create error log' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple error log entries in batch
|
||||
*/
|
||||
async createErrorLogsBatch(
|
||||
errors: CreateErrorLogDto[],
|
||||
appIdHeader?: string,
|
||||
userId?: string
|
||||
): Promise<{ success: boolean; total: number; succeeded: number; failed: number }> {
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const errorDto of errors) {
|
||||
const result = await this.createErrorLog(errorDto, appIdHeader, userId);
|
||||
if (result.success) {
|
||||
succeeded++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: failed === 0,
|
||||
total: errors.length,
|
||||
succeeded,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize headers to remove sensitive information
|
||||
*/
|
||||
private sanitizeHeaders(headers?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (SENSITIVE_HEADERS.includes(key.toLowerCase())) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize body to remove sensitive information
|
||||
*/
|
||||
private sanitizeBody(body?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!body) return undefined;
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (SENSITIVE_BODY_FIELDS.includes(key.toLowerCase())) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
sanitized[key] = this.sanitizeBody(value as Record<string, unknown>);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fingerprint for error grouping/deduplication
|
||||
*/
|
||||
private generateFingerprint(dto: CreateErrorLogDto, appId: string): string {
|
||||
const parts = [
|
||||
dto.errorCode,
|
||||
dto.errorType,
|
||||
appId,
|
||||
dto.requestMethod || '',
|
||||
this.extractPathFromUrl(dto.requestUrl),
|
||||
];
|
||||
|
||||
const hash = crypto.createHash('sha256').update(parts.join('|')).digest('hex');
|
||||
return hash.substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path from URL (without query parameters)
|
||||
*/
|
||||
private extractPathFromUrl(url?: string): string {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url, 'http://placeholder');
|
||||
return parsed.pathname;
|
||||
} catch {
|
||||
// If URL parsing fails, try to extract path manually
|
||||
const queryStart = url.indexOf('?');
|
||||
return queryStart > -1 ? url.substring(0, queryStart) : url;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue