diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.completed_.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.completed_.md
new file mode 100644
index 0000000000000..c9f8ab11f6b12
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.completed_.md
@@ -0,0 +1,18 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) > [completed$](./kibana-plugin-core-server.kibanarequestevents.completed_.md)
+
+## KibanaRequestEvents.completed$ property
+
+Observable that emits once if and when the request has been completely handled.
+
+Signature:
+
+```typescript
+completed$: Observable;
+```
+
+## Remarks
+
+The request may be considered completed if: - A response has been sent to the client; or - The request was aborted.
+
diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.md
index 21826c8b29383..dfd7efd27cb5a 100644
--- a/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.md
+++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.md
@@ -17,4 +17,5 @@ export interface KibanaRequestEvents
| Property | Type | Description |
| --- | --- | --- |
| [aborted$](./kibana-plugin-core-server.kibanarequestevents.aborted_.md) | Observable<void>
| Observable that emits once if and when the request has been aborted. |
+| [completed$](./kibana-plugin-core-server.kibanarequestevents.completed_.md) | Observable<void>
| Observable that emits once if and when the request has been completely handled. |
diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts
index 2d018f7f464b5..3a7335583296e 100644
--- a/src/core/server/http/integration_tests/request.test.ts
+++ b/src/core/server/http/integration_tests/request.test.ts
@@ -23,6 +23,7 @@ import { HttpService } from '../http_service';
import { contextServiceMock } from '../../context/context_service.mock';
import { loggingSystemMock } from '../../logging/logging_system.mock';
import { createHttpServer } from '../test_utils';
+import { schema } from '@kbn/config-schema';
let server: HttpService;
@@ -195,6 +196,96 @@ describe('KibanaRequest', () => {
expect(nextSpy).toHaveBeenCalledTimes(0);
expect(completeSpy).toHaveBeenCalledTimes(1);
});
+
+ it('does not complete before response has been sent', async () => {
+ const { server: innerServer, createRouter, registerOnPreAuth } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ const nextSpy = jest.fn();
+ const completeSpy = jest.fn();
+
+ registerOnPreAuth((req, res, toolkit) => {
+ req.events.aborted$.subscribe({
+ next: nextSpy,
+ complete: completeSpy,
+ });
+ return toolkit.next();
+ });
+
+ router.post(
+ { path: '/', validate: { body: schema.any() } },
+ async (context, request, res) => {
+ expect(completeSpy).not.toHaveBeenCalled();
+ return res.ok({ body: 'ok' });
+ }
+ );
+
+ await server.start();
+
+ await supertest(innerServer.listener).post('/').send({ data: 'test' }).expect(200);
+
+ expect(nextSpy).toHaveBeenCalledTimes(0);
+ expect(completeSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('completed$', () => {
+ it('emits once and completes when response is sent', async () => {
+ const { server: innerServer, createRouter } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ const nextSpy = jest.fn();
+ const completeSpy = jest.fn();
+
+ router.get({ path: '/', validate: false }, async (context, req, res) => {
+ req.events.completed$.subscribe({
+ next: nextSpy,
+ complete: completeSpy,
+ });
+
+ expect(nextSpy).not.toHaveBeenCalled();
+ expect(completeSpy).not.toHaveBeenCalled();
+ return res.ok({ body: 'ok' });
+ });
+
+ await server.start();
+
+ await supertest(innerServer.listener).get('/').expect(200);
+ expect(nextSpy).toHaveBeenCalledTimes(1);
+ expect(completeSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits once and completes when response is aborted', async (done) => {
+ expect.assertions(2);
+ const { server: innerServer, createRouter } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ const nextSpy = jest.fn();
+
+ router.get({ path: '/', validate: false }, async (context, req, res) => {
+ req.events.completed$.subscribe({
+ next: nextSpy,
+ complete: () => {
+ expect(nextSpy).toHaveBeenCalledTimes(1);
+ done();
+ },
+ });
+
+ expect(nextSpy).not.toHaveBeenCalled();
+ await delay(30000);
+ return res.ok({ body: 'ok' });
+ });
+
+ await server.start();
+
+ const incomingRequest = supertest(innerServer.listener)
+ .get('/')
+ // end required to send request
+ .end();
+ setTimeout(() => incomingRequest.abort(), 50);
+ });
});
});
});
diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts
index 0e73431fe7c6d..93ffb5aa48259 100644
--- a/src/core/server/http/router/request.ts
+++ b/src/core/server/http/router/request.ts
@@ -64,6 +64,16 @@ export interface KibanaRequestEvents {
* Observable that emits once if and when the request has been aborted.
*/
aborted$: Observable;
+
+ /**
+ * Observable that emits once if and when the request has been completely handled.
+ *
+ * @remarks
+ * The request may be considered completed if:
+ * - A response has been sent to the client; or
+ * - The request was aborted.
+ */
+ completed$: Observable;
}
/**
@@ -186,11 +196,16 @@ export class KibanaRequest<
private getEvents(request: Request): KibanaRequestEvents {
const finish$ = merge(
- fromEvent(request.raw.req, 'end'), // all data consumed
+ fromEvent(request.raw.res, 'finish'), // Response has been sent
fromEvent(request.raw.req, 'close') // connection was closed
).pipe(shareReplay(1), first());
+
+ const aborted$ = fromEvent(request.raw.req, 'aborted').pipe(first(), takeUntil(finish$));
+ const completed$ = merge(finish$, aborted$).pipe(shareReplay(1), first());
+
return {
- aborted$: fromEvent(request.raw.req, 'aborted').pipe(first(), takeUntil(finish$)),
+ aborted$,
+ completed$,
} as const;
}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 48b480f2f61d2..c3cd219f2b8ec 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -974,6 +974,7 @@ export class KibanaRequest;
+ completed$: Observable;
}
// @public