Skip to content

Commit 20e5c43

Browse files
fix : Update get Deployment logs query to GetDeploymentLogsV2 to match the updated windowed deployment logs query
1 parent c6606bd commit 20e5c43

3 files changed

Lines changed: 308 additions & 6 deletions

File tree

src/graphql/queries.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ const deploymentLogsQuery: DocumentNode = gql`
139139
}
140140
`;
141141

142+
143+
const deploymentLogsV2Query: DocumentNode = gql`
144+
query GetDeploymentLogsV2($query: DeploymentLogsV2QueryInput!) {
145+
getDeploymentLogsV2(query: $query) {
146+
logs {
147+
deploymentUid
148+
message
149+
stage
150+
timestamp
151+
}
152+
pageInfo {
153+
hasNewer
154+
newestCursor
155+
}
156+
}
157+
}
158+
`;
159+
142160
const serverlessLogsQuery: DocumentNode = gql`
143161
query GetServerlessLogsV2($query: QueryLogMessagesV2InputType!) {
144162
getServerlessLogsV2(query: $query) {
@@ -206,6 +224,7 @@ export {
206224
cmsEnvironmentVariablesQuery,
207225
deploymentQuery,
208226
deploymentLogsQuery,
227+
deploymentLogsV2Query,
209228
serverlessLogsQuery,
210229
latestLiveDeploymentQuery,
211230
environmentsQuery,

src/util/logs-polling-utilities.test.ts

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ type LogPollingCtor = typeof import('./logs-polling-utilities').default;
88
jest.mock('@contentstack/cli-utilities', () => cliUtilitiesJestMock);
99
jest.mock('timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) }));
1010

11+
const CONFIG = {
12+
deployment: 'd1',
13+
environment: 'e1',
14+
pollingInterval: 1000,
15+
};
16+
1117
function makeWatchQuery() {
1218
let subscriber: (result: any) => void = () => {};
1319
return {
@@ -21,11 +27,12 @@ function makeWatchQuery() {
2127
};
2228
}
2329

24-
const CONFIG = {
25-
deployment: 'd1',
26-
environment: 'e1',
27-
pollingInterval: 1000,
28-
};
30+
function page(logs: any[], pageInfo: Record<string, unknown> = {}) {
31+
return {
32+
logs,
33+
pageInfo: { hasNewer: null, newestCursor: null, ...pageInfo },
34+
};
35+
}
2936

3037
function getDeploymentStatus(LogPollingClass: LogPollingCtor, watchQuery: jest.Mock): void {
3138
new LogPollingClass({
@@ -195,3 +202,125 @@ describe('cancelled deployment stops log polling', () => {
195202
expect(defaultConfig.deploymentStatus).toContain('CANCELLED');
196203
});
197204
});
205+
206+
describe('deployment logs use cursor paging (getDeploymentLogsV2)', () => {
207+
function buildInstance(deploymentStatus: string[] = ['DEPLOYED']) {
208+
const statusWatchQuery = makeWatchQuery();
209+
const logsWatchQuery = makeWatchQuery();
210+
const fallbackWatchQuery = makeWatchQuery();
211+
const logsClientWatchQuery = jest
212+
.fn()
213+
.mockReturnValueOnce(logsWatchQuery)
214+
.mockReturnValue(fallbackWatchQuery);
215+
const instance = new LogPolling({
216+
apolloManageClient: { watchQuery: jest.fn().mockReturnValue(statusWatchQuery) } as any,
217+
apolloLogsClient: { watchQuery: logsClientWatchQuery } as any,
218+
config: { deployment: 'd1', environment: 'e1', pollingInterval: 1000, deploymentStatus } as any,
219+
$event: new EventEmitter(),
220+
});
221+
return { instance, statusWatchQuery, logsWatchQuery, fallbackWatchQuery, logsClientWatchQuery };
222+
}
223+
224+
it('opens with sortDirection desc and no cursor, tailing the newest page like the legacy query did', async () => {
225+
const { instance, logsClientWatchQuery } = buildInstance();
226+
227+
await instance.deploymentLogs();
228+
229+
const { query } = logsClientWatchQuery.mock.calls[0][0].variables;
230+
expect(query).toEqual({ deploymentUid: 'd1', limit: 5000, sortDirection: 'desc' });
231+
expect(query).not.toHaveProperty('cursor');
232+
});
233+
234+
it('advances by cursor in asc order — never by timestamp', async () => {
235+
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);
236+
237+
await instance.deploymentLogs();
238+
statusWatchQuery.emit({ data: { Deployment: { status: 'LIVE' } } });
239+
await logsWatchQuery.emit({
240+
data: {
241+
getDeploymentLogsV2: page([{ message: 'build started', timestamp: '2026-08-06T10:00:00.123Z' }], {
242+
newestCursor: '[1775462400123,"abc"]',
243+
}),
244+
},
245+
});
246+
247+
expect(logsWatchQuery.setVariables).toHaveBeenCalledWith({
248+
query: {
249+
deploymentUid: 'd1',
250+
limit: 5000,
251+
sortDirection: 'asc',
252+
cursor: '[1775462400123,"abc"]',
253+
},
254+
});
255+
});
256+
257+
it('does not re-arm when the cursor has not moved, so a repeated page cannot loop forever', async () => {
258+
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);
259+
260+
await instance.deploymentLogs();
261+
statusWatchQuery.emit({ data: { Deployment: { status: 'LIVE' } } });
262+
const samePage = {
263+
data: {
264+
getDeploymentLogsV2: page([{ message: 'x', timestamp: '2026-08-06T10:00:00.000Z' }], {
265+
newestCursor: 'c1',
266+
}),
267+
},
268+
};
269+
await logsWatchQuery.emit(samePage);
270+
await logsWatchQuery.emit(samePage);
271+
272+
expect(logsWatchQuery.setVariables).toHaveBeenCalledTimes(1);
273+
});
274+
275+
it('keeps draining past a terminal status while hasNewer reports another page', async () => {
276+
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);
277+
278+
await instance.deploymentLogs();
279+
statusWatchQuery.emit({ data: { Deployment: { status: 'DEPLOYED' } } });
280+
await logsWatchQuery.emit({
281+
data: { getDeploymentLogsV2: page([{ message: 'a', timestamp: 'x' }], { hasNewer: true, newestCursor: 'c1' }) },
282+
});
283+
284+
expect(logsWatchQuery.stopPolling).not.toHaveBeenCalled();
285+
286+
await logsWatchQuery.emit({
287+
data: { getDeploymentLogsV2: page([{ message: 'b', timestamp: 'y' }], { hasNewer: false, newestCursor: 'c2' }) },
288+
});
289+
290+
expect(logsWatchQuery.stopPolling).toHaveBeenCalledTimes(1);
291+
});
292+
293+
it('falls back to the legacy getLogs query when the region has no V2 field', async () => {
294+
const { instance, logsWatchQuery, fallbackWatchQuery, logsClientWatchQuery } = buildInstance();
295+
const errors: any[] = [];
296+
(instance as any).$event.on('deployment-logs', (e: any) => {
297+
if (e.msgType === 'error') errors.push(e.message);
298+
});
299+
300+
await instance.deploymentLogs();
301+
await logsWatchQuery.emit({
302+
data: null,
303+
error: { message: 'Cannot query field "getDeploymentLogsV2" on type "Query".' },
304+
});
305+
306+
expect(logsWatchQuery.stopPolling).toHaveBeenCalledTimes(1);
307+
expect(logsClientWatchQuery).toHaveBeenCalledTimes(2);
308+
expect(logsClientWatchQuery.mock.calls[1][0].variables).toEqual({ deploymentUid: 'd1' });
309+
expect(fallbackWatchQuery.subscribe).toHaveBeenCalledTimes(1);
310+
expect(errors).toHaveLength(0);
311+
});
312+
313+
it('does not demote to the legacy query on a transient network error', async () => {
314+
const { instance, logsWatchQuery, logsClientWatchQuery } = buildInstance();
315+
const errors: any[] = [];
316+
(instance as any).$event.on('deployment-logs', (e: any) => {
317+
if (e.msgType === 'error') errors.push(e.message);
318+
});
319+
320+
await instance.deploymentLogs();
321+
await logsWatchQuery.emit({ data: null, error: { message: 'Failed to fetch' } });
322+
323+
expect(logsClientWatchQuery).toHaveBeenCalledTimes(1);
324+
expect(errors).toContain('Failed to fetch');
325+
});
326+
});

src/util/logs-polling-utilities.ts

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,23 @@ import { ApolloClient, ObservableQuery } from '@apollo/client/core';
55
import { Ora } from 'ora';
66

77
import { LogPollingInput, ConfigType } from '../types';
8-
import { deploymentQuery, deploymentLogsQuery, serverlessLogsQuery } from '../graphql';
8+
import {
9+
deploymentQuery,
10+
deploymentLogsQuery,
11+
deploymentLogsV2Query,
12+
serverlessLogsQuery,
13+
} from '../graphql';
914
import { setTimeout as sleep } from 'timers/promises';
1015
import { isNotDevelopment } from './apollo-client';
1116

1217
const requireApolloDeprecation = createRequire(__filename);
1318

1419
export default class LogPolling {
20+
private static readonly DEPLOYMENT_LOGS_PAGE_SIZE = 5_000;
21+
22+
private static readonly V2_UNSUPPORTED_PATTERN =
23+
/cannot query field ["'`]?getDeploymentLogsV2|unknown type ["'`]?DeploymentLogsV2QueryInput/i;
24+
1525
private config: ConfigType;
1626
private $event!: EventEmitter;
1727
private apolloLogsClient!: ApolloClient<any>;
@@ -20,6 +30,8 @@ export default class LogPolling {
2030
public startTime!: number;
2131
public endTime!: number;
2232
public loader!: Ora | void;
33+
private deploymentLogsCursor: string | null = null;
34+
private deploymentLogsV1FallbackStarted = false;
2335

2436
constructor(params: LogPollingInput) {
2537
const { apolloLogsClient, apolloManageClient, config, $event } = params;
@@ -159,6 +171,63 @@ export default class LogPolling {
159171
statusWatchQuery.stopPolling();
160172
}
161173
});
174+
const logsWatchQuery = this.withDeprecationsDisabled(() => {
175+
return this.apolloLogsClient.watchQuery({
176+
fetchPolicy: 'network-only',
177+
query: deploymentLogsV2Query,
178+
variables: {
179+
query: this.deploymentLogsV2Variables(),
180+
},
181+
pollInterval: this.config.pollingInterval,
182+
errorPolicy: 'all',
183+
});
184+
});
185+
this.subscribeDeploymentLogsV2(logsWatchQuery);
186+
}
187+
188+
/**
189+
* @method deploymentLogsV2Variables - build the getDeploymentLogsV2 query input
190+
*
191+
* @return {*} {Record<string, unknown>}
192+
* @memberof LogPolling
193+
*/
194+
private deploymentLogsV2Variables(): Record<string, unknown> {
195+
return {
196+
deploymentUid: this.config.deployment,
197+
limit: LogPolling.DEPLOYMENT_LOGS_PAGE_SIZE,
198+
sortDirection: this.deploymentLogsCursor ? 'asc' : 'desc',
199+
...(this.deploymentLogsCursor ? { cursor: this.deploymentLogsCursor } : {}),
200+
};
201+
}
202+
203+
/**
204+
* @method isUnsupportedQueryError - detect a logs service with no getDeploymentLogsV2
205+
*
206+
* @return {*} {boolean}
207+
* @memberof LogPolling
208+
*/
209+
private isUnsupportedQueryError(error: any, errors?: readonly any[] | null): boolean {
210+
const messages: string[] = [];
211+
if (error?.message) messages.push(error.message);
212+
for (const graphQLError of error?.graphQLErrors ?? []) {
213+
if (graphQLError?.message) messages.push(graphQLError.message);
214+
}
215+
for (const graphQLError of errors ?? []) {
216+
if (graphQLError?.message) messages.push(graphQLError.message);
217+
}
218+
return messages.some((message) => LogPolling.V2_UNSUPPORTED_PATTERN.test(message));
219+
}
220+
221+
/**
222+
* @method fallBackToDeploymentLogsV1 - re-poll through the legacy getLogs query
223+
*
224+
* @return {*} {void}
225+
* @memberof LogPolling
226+
*/
227+
private fallBackToDeploymentLogsV1(): void {
228+
if (this.deploymentLogsV1FallbackStarted) return;
229+
this.deploymentLogsV1FallbackStarted = true;
230+
162231
const logsWatchQuery = this.withDeprecationsDisabled(() => {
163232
return this.apolloLogsClient.watchQuery({
164233
fetchPolicy: 'network-only',
@@ -173,6 +242,91 @@ export default class LogPolling {
173242
this.subscribeDeploymentLogs(logsWatchQuery);
174243
}
175244

245+
/**
246+
* @method subscribeDeploymentLogsV2 - subscribe cursor-paged deployment logs
247+
*
248+
* @return {*} {void}
249+
* @memberof LogPolling
250+
*/
251+
subscribeDeploymentLogsV2(
252+
logsWatchQuery: ObservableQuery<
253+
any,
254+
{
255+
query: Record<string, unknown>;
256+
}
257+
>,
258+
): void {
259+
logsWatchQuery.subscribe(async({ data, errors, error }) => {
260+
if(!this.loader){
261+
this.loader = cliux.loaderV2('Loading deployment logs...');
262+
}
263+
if (this.isUnsupportedQueryError(error, errors)) {
264+
logsWatchQuery.stopPolling();
265+
this.fallBackToDeploymentLogsV1();
266+
return;
267+
}
268+
if (error) {
269+
this.loader=cliux.loaderV2('done', this.loader);
270+
this.$event.emit('deployment-logs', {
271+
message: error?.message,
272+
msgType: 'error',
273+
});
274+
this.$event.emit('deployment-logs', {
275+
message: 'DONE',
276+
msgType: 'debug',
277+
});
278+
logsWatchQuery.stopPolling();
279+
}
280+
if (errors?.length && data === null) {
281+
this.loader=cliux.loaderV2('done', this.loader);
282+
this.$event.emit('deployment-logs', {
283+
message: errors,
284+
msgType: 'error',
285+
});
286+
this.$event.emit('deployment-logs', {
287+
message: 'DONE',
288+
msgType: 'debug',
289+
});
290+
logsWatchQuery.stopPolling();
291+
}
292+
if (this.deploymentStatus) {
293+
const page = data?.getDeploymentLogsV2;
294+
const logsData = page?.logs;
295+
const hasNewer = page?.pageInfo?.hasNewer === true;
296+
let advanced = false;
297+
298+
if (logsData?.length) {
299+
this.loader=cliux.loaderV2('done', this.loader);
300+
this.$event.emit('deployment-logs', {
301+
message: logsData,
302+
msgType: 'info',
303+
});
304+
305+
const nextCursor = page?.pageInfo?.newestCursor;
306+
if (nextCursor && nextCursor !== this.deploymentLogsCursor) {
307+
this.deploymentLogsCursor = nextCursor;
308+
advanced = true;
309+
logsWatchQuery.setVariables({
310+
query: this.deploymentLogsV2Variables(),
311+
} as any);
312+
}
313+
}
314+
315+
if (this.config.deploymentStatus.includes(this.deploymentStatus) && !(hasNewer && advanced)) {
316+
await sleep(1_000);
317+
logsWatchQuery.stopPolling();
318+
this.$event.emit('deployment-logs', {
319+
message: 'DONE',
320+
msgType: 'debug',
321+
});
322+
if(this.loader){
323+
this.loader=cliux.loaderV2('done', this.loader);
324+
}
325+
}
326+
}
327+
});
328+
}
329+
176330
/**
177331
* @method subscribeDeploymentLogs - subscribe deployment logs
178332
*

0 commit comments

Comments
 (0)