refactor(API): Migrate the log streaming destination test and delete to the decorator pattern (no-changelog) (#40532)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Uddish Verma 2026-10-07 16:43:03 +00:00 committed by GitHub
parent bef5f3f97a
commit 9d3eb0c637
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 146 additions and 409 deletions

View File

@ -340,6 +340,7 @@ export {
LogStreamingDestinationListPublicDto,
LogStreamingDestinationPublicDto,
LogStreamingEventTypesPublicDto,
LogStreamingTestResultPublicDto,
CreateLogStreamingDestinationPublicDto,
UpdateLogStreamingDestinationPublicDto,
type LogStreamingDestinationPublic,

View File

@ -21,6 +21,7 @@ import {
logStreamingParameterListDocs,
logStreamingSentryFieldDocs,
logStreamingSyslogFieldDocs,
logStreamingTestResultFieldDocs,
logStreamingWebhookFieldDocs,
logStreamingWebhookOptionsFieldDocs,
} from './log-streaming-public.openapi';
@ -39,6 +40,11 @@ export class LogStreamingEventTypesPublicDto extends Z.class(
static schema = logStreamingEventTypesPublicSchema;
}
export class LogStreamingTestResultPublicDto extends Z.class(
{ success: z.boolean().openapi(logStreamingTestResultFieldDocs.success) },
{ strict: true },
) {}
// Only the fields the log streaming UI exposes. Credentials and auth settings stay out, so a
// read never returns them.

View File

@ -128,3 +128,10 @@ export const logStreamingSentryFieldDocs = {
example: 'https://examplePublicKey@o0.ingest.sentry.io/0',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const logStreamingTestResultFieldDocs = {
success: {
description: 'Whether the test message was delivered to the destination successfully.',
example: true,
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -162,10 +162,7 @@ export default defineConfig(
// Ratchet allowlist: legacy `export =` handler tuples pending migration to
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
// must fail CI. Entries are removed as each handler becomes a controller.
files: [
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
],
files: ['./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts'],
rules: {
'n8n-local-rules/require-public-api-controller': 'off',
},

View File

@ -155,11 +155,7 @@ export default defineConfig({
// Ratchet allowlist: legacy `export =` handler tuples pending migration to
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
// must fail CI. Entries are removed as each handler becomes a controller.
files: [
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
],
files: ['./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts'],
rules: {
'n8n-local-rules/require-public-api-controller': 'off',
},

View File

@ -113,11 +113,6 @@ export declare namespace CommunityPackageRequest {
type Uninstall = AuthenticatedRequest<{ name: string }>;
}
export declare namespace LogStreamingRequest {
type TestDestination = AuthenticatedRequest<{ id: string }>;
type DeleteDestination = AuthenticatedRequest<{ id: string }>;
}
// ----------------------------------
// /settings/otel
// ----------------------------------

View File

@ -4,6 +4,7 @@ import {
LogStreamingDestinationListPublicDto,
LogStreamingDestinationPublicDto,
LogStreamingEventTypesPublicDto,
LogStreamingTestResultPublicDto,
logStreamingDestinationIdParamSchema,
} from '@n8n/api-types';
import { OutboundHttp } from '@n8n/backend-network';
@ -19,6 +20,7 @@ import {
ApiSummary,
ApiTags,
Body,
Delete,
Get,
Licensed,
Param,
@ -145,6 +147,61 @@ export class LogStreamingPublicController {
return await this.saveDestination(req.user, { ...toInternalDestinationOptions(body), id });
}
@Post('/destinations/:id/test')
@ApiKeyScope('eventBusDestination:test')
@Licensed(LICENSE_FEATURES.LOG_STREAMING)
@ApiSummary('Send a test message to a log streaming destination')
@ApiDescription(
'Send a test message to the destination to verify it is reachable and configured correctly. Requires the `eventBusDestination:test` scope and the Log Streaming feature to be licensed.',
)
@ApiTags(tags)
@ApiResponse(200, LogStreamingTestResultPublicDto)
@ApiErrorResponse(404)
async testLogStreamingDestination(
req: AuthenticatedRequest,
_res: Response,
@Param('id', logStreamingDestinationIdParamSchema) id: string,
): Promise<LogStreamingTestResultPublicDto> {
const destination = await this.findDestinationOrFail(id);
await assertUserCanUseDestinationCredentials(
this.credentialsFinderService,
req.user,
destination,
);
// A delivery failure is a failed test, not a server error.
try {
const result = await this.destinationService.testDestination(id);
return { success: result };
} catch {
return { success: false };
}
}
@Delete('/destinations/:id')
@ApiKeyScope('eventBusDestination:delete')
@Licensed(LICENSE_FEATURES.LOG_STREAMING)
@ApiSummary('Delete a log streaming destination')
@ApiDescription(
'Remove a log streaming destination. Requires the `eventBusDestination:delete` scope and the Log Streaming feature to be licensed. When destinations are managed via environment variables, the delete is rejected with 409 and nothing is removed; reads still return the current values.',
)
@ApiTags(tags)
@ApiResponse(200, LogStreamingDestinationPublicDto)
@ApiErrorResponse(404)
@ApiErrorResponse(409)
async deleteLogStreamingDestination(
_req: AuthenticatedRequest,
_res: Response,
@Param('id', logStreamingDestinationIdParamSchema) id: string,
): Promise<LogStreamingDestinationPublicDto> {
this.assertNotManagedByEnv();
const destination = await this.findDestinationOrFail(id);
await this.destinationService.removeDestination(id);
return toLogStreamingDestinationPublic(destination);
}
private assertNotManagedByEnv() {
if (this.instanceSettingsLoaderConfig.logStreamingManagedByEnv) {
throw new ConflictError(

View File

@ -1,79 +0,0 @@
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { MessageEventBusDestinationOptions } from 'n8n-workflow';
import { ConflictError, NotFoundError } from '@n8n/errors';
import { assertUserCanUseDestinationCredentials } from '@/modules/log-streaming.ee/destinations/destination-credentials-access';
import { LogStreamingDestinationService } from '@/modules/log-streaming.ee/log-streaming-destination.service';
import { toLogStreamingDestinationPublic } from '../../controllers/log-streaming.mapper';
import type { LogStreamingRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import {
apiKeyHasScopeWithGlobalScopeFallback,
isLicensed,
} from '../../shared/middlewares/global.middleware';
const assertNotManagedByEnv = () => {
if (Container.get(InstanceSettingsLoaderConfig).logStreamingManagedByEnv) {
throw new ConflictError(
'Log streaming destinations are managed via environment variables and cannot be modified through the API',
);
}
};
const findDestinationOrFail = async (id: string): Promise<MessageEventBusDestinationOptions> => {
const [destination] = await Container.get(LogStreamingDestinationService).findDestination(id);
if (!destination) {
throw new NotFoundError(`Log streaming destination with id "${id}" could not be found`);
}
return destination;
};
const getCredentialsFinderService = async () => {
const { CredentialsFinderService } = await import('@n8n/backend-services');
return Container.get(CredentialsFinderService);
};
type LogStreamingHandlers = {
testDestination: PublicAPIEndpoint<LogStreamingRequest.TestDestination>;
deleteDestination: PublicAPIEndpoint<LogStreamingRequest.DeleteDestination>;
};
const logStreamingHandlers: LogStreamingHandlers = {
testDestination: [
isLicensed('feat:logStreaming'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'eventBusDestination:test' }),
async (req, res) => {
const destination = await findDestinationOrFail(req.params.id);
await assertUserCanUseDestinationCredentials(
await getCredentialsFinderService(),
req.user,
destination,
);
// a delivery failure is a failed test, not a server error → { success: false }
let success: boolean;
try {
success = await Container.get(LogStreamingDestinationService).testDestination(
req.params.id,
);
} catch {
success = false;
}
return res.json({ success });
},
],
deleteDestination: [
isLicensed('feat:logStreaming'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'eventBusDestination:delete' }),
async (req, res) => {
assertNotManagedByEnv();
const destination = await findDestinationOrFail(req.params.id);
await Container.get(LogStreamingDestinationService).removeDestination(req.params.id);
return res.json(toLogStreamingDestinationPublic(destination));
},
],
};
export = logStreamingHandlers;

View File

@ -1,30 +0,0 @@
post:
x-eov-operation-id: testDestination
x-required-scope: eventBusDestination:test
x-eov-operation-handler: v1/handlers/log-streaming/log-streaming.handler
tags:
- LogStreaming
summary: Send a test message to a log streaming destination
description: >
Send a test message to the destination to verify it is reachable and configured correctly.
Requires the `eventBusDestination:test` scope and the Log Streaming feature to be licensed.
parameters:
- name: id
in: path
description: The id of the log streaming destination.
required: true
schema:
type: string
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/test-result.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View File

@ -1,34 +0,0 @@
delete:
x-eov-operation-id: deleteDestination
x-required-scope: eventBusDestination:delete
x-eov-operation-handler: v1/handlers/log-streaming/log-streaming.handler
tags:
- LogStreaming
summary: Delete a log streaming destination
description: >
Remove a log streaming destination. Requires the `eventBusDestination:delete` scope and the
Log Streaming feature to be licensed. When destinations are managed via environment
variables, the delete is rejected with 409 and nothing is removed; reads still return the
current values.
parameters:
- name: id
in: path
description: The id of the log streaming destination.
required: true
schema:
type: string
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/destination.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
'409':
$ref: '../../../../shared/spec/responses/conflict.yml'

View File

@ -1,42 +0,0 @@
type: object
properties:
id:
type: string
readOnly: true
description: Server-generated unique id of the destination. Returned in responses; not accepted in requests.
example: 88be6560-bfb4-455c-8aa1-06971e9e5522
label:
type: string
description: Human-readable name shown in the UI.
example: My destination
enabled:
type: boolean
description: Whether the destination currently receives events.
example: true
subscribedEvents:
type: array
items:
type: string
description: >
Event names (or group prefixes, e.g. `n8n.workflow`, which matches all
`n8n.workflow.*` events) this destination receives. Retrieve the full list of
streamable event names from `GET /settings/log-streaming/event-types`.
example: [n8n.workflow, n8n.audit]
anonymizeAuditMessages:
type: boolean
description: Whether audit message payloads are anonymized before being sent.
example: false
circuitBreaker:
type: object
additionalProperties: true
description: >
Circuit-breaker tuning for delivery. After repeated failures the destination stops
sending for a cool-down window instead of hammering an unhealthy target. All fields are
optional; sensible defaults apply when omitted.
properties:
maxFailures:
type: integer
description: Maximum failures within the sliding window before the breaker opens.
failureWindow:
type: integer
description: Sliding window, in milliseconds, over which failures are counted.

View File

@ -1,18 +0,0 @@
allOf:
- $ref: './destination.common.yml'
- type: object
additionalProperties: true
required:
- type
- dsn
properties:
type:
type: string
enum: [sentry]
description: Sends each event to a Sentry project.
example: sentry
dsn:
type: string
format: uri
description: Sentry DSN the events are sent to.
example: https://examplePublicKey@o0.ingest.sentry.io/0

View File

@ -1,42 +0,0 @@
allOf:
- $ref: './destination.common.yml'
- type: object
additionalProperties: true
required:
- type
- host
properties:
type:
type: string
enum: [syslog]
description: Sends each event to a syslog server.
example: syslog
host:
type: string
description: Syslog server host.
example: syslog.example.com
port:
type: integer
description: Syslog server port.
example: 514
protocol:
type: string
enum: [udp, tcp, tls]
description: Transport protocol.
example: udp
facility:
type: integer
minimum: 0
maximum: 23
description: >
Syslog facility code (0–23, per RFC 5424). Common values: 0 Kernel, 1 User,
3 System, 13 Audit, 14 Alert, 16 Local0, 17 Local1, 18 Local2, 19 Local3,
20 Local4, 21 Local5, 22 Local6, 23 Local7.
example: 16
app_name:
type: string
description: Application name reported in the syslog message.
example: n8n
tlsCa:
type: string
description: PEM-encoded CA certificate used when `protocol` is `tls`.

View File

@ -1,110 +0,0 @@
allOf:
- $ref: './destination.common.yml'
- type: object
additionalProperties: true
required:
- type
- url
properties:
type:
type: string
enum: [webhook]
description: Sends each event as an HTTP request to a URL.
example: webhook
url:
type: string
format: uri
description: Target URL that receives the event payload.
example: https://example.com/n8n-events
method:
type: string
description: HTTP method used for the request.
example: POST
sendHeaders:
type: boolean
description: Whether to attach custom headers to the request.
example: false
specifyHeaders:
type: string
description: How custom headers are provided — `keypair` or `json`.
example: keypair
headerParameters:
$ref: './parameter-list.yml'
jsonHeaders:
type: string
description: Custom headers as a JSON string when `specifyHeaders` is `json`.
sendQuery:
type: boolean
description: Whether to attach query parameters to the request.
example: false
specifyQuery:
type: string
description: How query parameters are provided — `keypair` or `json`.
queryParameters:
$ref: './parameter-list.yml'
jsonQuery:
type: string
description: Query parameters as a JSON string when `specifyQuery` is `json`.
options:
type: object
additionalProperties: true
description: Additional HTTP request options.
properties:
timeout:
type: integer
description: Request timeout in milliseconds.
allowUnauthorizedCerts:
type: boolean
description: Accept self-signed or otherwise invalid TLS certificates.
queryParameterArrays:
type: string
enum: [indices, brackets, repeat]
description: How array-valued query parameters are serialized into the query string.
redirect:
type: object
additionalProperties: true
description: Redirect-following behaviour.
properties:
redirect:
type: object
additionalProperties: true
properties:
followRedirects:
type: boolean
description: Whether to follow HTTP redirects.
maxRedirects:
type: integer
description: Maximum number of redirects to follow.
proxy:
type: object
additionalProperties: true
description: Outbound proxy configuration.
properties:
proxy:
type: object
additionalProperties: true
properties:
protocol:
type: string
enum: [https, http]
description: Proxy protocol.
host:
type: string
description: Proxy host, without protocol or port.
port:
type: integer
description: Proxy port.
socket:
type: object
additionalProperties: true
description: Connection socket options.
properties:
keepAlive:
type: boolean
description: Whether to keep sockets open for reuse.
maxSockets:
type: integer
description: Maximum sockets per host kept open at once.
maxFreeSockets:
type: integer
description: Maximum idle sockets per host kept open.

View File

@ -1,8 +0,0 @@
type: object
oneOf:
- $ref: './destination.webhook.yml'
- $ref: './destination.syslog.yml'
- $ref: './destination.sentry.yml'
description: >
A log streaming destination. The `type` field selects the variant and its
type-specific fields: `webhook`, `syslog`, or `sentry`.

View File

@ -1,19 +0,0 @@
type: object
additionalProperties: true
description: Key-value pairs used when the matching `specify*` field is `keypair`.
properties:
parameters:
type: array
items:
type: object
required:
- name
- value
properties:
name:
type: string
example: Authorization
value:
description: >
Parameter value. Usually a string; numbers, booleans and null are also accepted.
example: Bearer <token>

View File

@ -1,9 +0,0 @@
type: object
additionalProperties: false
required:
- success
properties:
success:
type: boolean
description: Whether the test message was delivered to the destination successfully.
example: true

View File

@ -0,0 +1,31 @@
operationId: deleteLogStreamingDestination
tags:
- LogStreaming
summary: Delete a log streaming destination
description: Remove a log streaming destination. Requires the `eventBusDestination:delete` scope and the Log Streaming feature to be licensed. When destinations are managed via environment variables, the delete is rejected with 409 and nothing is removed; reads still return the current values.
x-required-scope: eventBusDestination:delete
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The id of the log streaming destination.
name: id
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: ../../../../shared/spec/schemas/logStreamingDestinationPublicDto.generated.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'409':
$ref: ../../../../shared/spec/responses/conflict.yml

View File

@ -0,0 +1,37 @@
operationId: testLogStreamingDestination
tags:
- LogStreaming
summary: Send a test message to a log streaming destination
description: Send a test message to the destination to verify it is reachable and configured correctly. Requires the `eventBusDestination:test` scope and the Log Streaming feature to be licensed.
x-required-scope: eventBusDestination:test
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The id of the log streaming destination.
name: id
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
description: Whether the test message was delivered to the destination successfully.
example: true
required:
- success
additionalProperties: false
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -178,6 +178,11 @@ paths:
$ref: ./handlers/settings/spec/paths/getLogStreamingDestination.generated.yml
put:
$ref: ./handlers/settings/spec/paths/updateLogStreamingDestination.generated.yml
delete:
$ref: ./handlers/settings/spec/paths/deleteLogStreamingDestination.generated.yml
/settings/log-streaming/destinations/{id}/test:
post:
$ref: ./handlers/settings/spec/paths/testLogStreamingDestination.generated.yml
/node-type-policies/instance:
get:
$ref: ./handlers/node-type-policies/spec/paths/getNodeTypeInstancePolicy.generated.yml

View File

@ -85,10 +85,6 @@ paths:
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.import.yml'
/n8n-packages/import-selection:
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.import-selection.yml'
/settings/log-streaming/destinations/{id}/test:
$ref: './handlers/log-streaming/spec/paths/destinations.id.test.yml'
/settings/log-streaming/destinations/{id}:
$ref: './handlers/log-streaming/spec/paths/destinations.id.yml'
components:
schemas:
$ref: './shared/spec/schemas/_index.yml'