Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/graphql/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ const deploymentLogsQuery: DocumentNode = gql`
}
`;


const deploymentLogsV2Query: DocumentNode = gql`
query GetDeploymentLogsV2($query: DeploymentLogsV2QueryInput!) {
getDeploymentLogsV2(query: $query) {
logs {
deploymentUid
message
stage
timestamp
}
pageInfo {
hasNewer
newestCursor
}
}
}
`;

const serverlessLogsQuery: DocumentNode = gql`
query GetServerlessLogsV2($query: QueryLogMessagesV2InputType!) {
getServerlessLogsV2(query: $query) {
Expand Down Expand Up @@ -206,6 +224,7 @@ export {
cmsEnvironmentVariablesQuery,
deploymentQuery,
deploymentLogsQuery,
deploymentLogsV2Query,
serverlessLogsQuery,
latestLiveDeploymentQuery,
environmentsQuery,
Expand Down
139 changes: 134 additions & 5 deletions src/util/logs-polling-utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ type LogPollingCtor = typeof import('./logs-polling-utilities').default;
jest.mock('@contentstack/cli-utilities', () => cliUtilitiesJestMock);
jest.mock('timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) }));

const CONFIG = {
deployment: 'd1',
environment: 'e1',
pollingInterval: 1000,
};

function makeWatchQuery() {
let subscriber: (result: any) => void = () => {};
return {
Expand All @@ -21,11 +27,12 @@ function makeWatchQuery() {
};
}

const CONFIG = {
deployment: 'd1',
environment: 'e1',
pollingInterval: 1000,
};
function page(logs: any[], pageInfo: Record<string, unknown> = {}) {
return {
logs,
pageInfo: { hasNewer: null, newestCursor: null, ...pageInfo },
};
}

function getDeploymentStatus(LogPollingClass: LogPollingCtor, watchQuery: jest.Mock): void {
new LogPollingClass({
Expand Down Expand Up @@ -195,3 +202,125 @@ describe('cancelled deployment stops log polling', () => {
expect(defaultConfig.deploymentStatus).toContain('CANCELLED');
});
});

describe('deployment logs use cursor paging (getDeploymentLogsV2)', () => {
function buildInstance(deploymentStatus: string[] = ['DEPLOYED']) {
const statusWatchQuery = makeWatchQuery();
const logsWatchQuery = makeWatchQuery();
const fallbackWatchQuery = makeWatchQuery();
const logsClientWatchQuery = jest
.fn()
.mockReturnValueOnce(logsWatchQuery)
.mockReturnValue(fallbackWatchQuery);
const instance = new LogPolling({
apolloManageClient: { watchQuery: jest.fn().mockReturnValue(statusWatchQuery) } as any,
apolloLogsClient: { watchQuery: logsClientWatchQuery } as any,
config: { deployment: 'd1', environment: 'e1', pollingInterval: 1000, deploymentStatus } as any,
$event: new EventEmitter(),
});
return { instance, statusWatchQuery, logsWatchQuery, fallbackWatchQuery, logsClientWatchQuery };
}

it('opens with sortDirection desc and no cursor, tailing the newest page like the legacy query did', async () => {
const { instance, logsClientWatchQuery } = buildInstance();

await instance.deploymentLogs();

const { query } = logsClientWatchQuery.mock.calls[0][0].variables;
expect(query).toEqual({ deploymentUid: 'd1', limit: 5000, sortDirection: 'desc' });
expect(query).not.toHaveProperty('cursor');
});

it('advances by cursor in asc order — never by timestamp', async () => {
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);

await instance.deploymentLogs();
statusWatchQuery.emit({ data: { Deployment: { status: 'LIVE' } } });
await logsWatchQuery.emit({
data: {
getDeploymentLogsV2: page([{ message: 'build started', timestamp: '2026-08-06T10:00:00.123Z' }], {
newestCursor: '[1775462400123,"abc"]',
}),
},
});

expect(logsWatchQuery.setVariables).toHaveBeenCalledWith({
query: {
deploymentUid: 'd1',
limit: 5000,
sortDirection: 'asc',
cursor: '[1775462400123,"abc"]',
},
});
});

it('does not re-arm when the cursor has not moved, so a repeated page cannot loop forever', async () => {
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);

await instance.deploymentLogs();
statusWatchQuery.emit({ data: { Deployment: { status: 'LIVE' } } });
const samePage = {
data: {
getDeploymentLogsV2: page([{ message: 'x', timestamp: '2026-08-06T10:00:00.000Z' }], {
newestCursor: 'c1',
}),
},
};
await logsWatchQuery.emit(samePage);
await logsWatchQuery.emit(samePage);

expect(logsWatchQuery.setVariables).toHaveBeenCalledTimes(1);
});

it('keeps draining past a terminal status while hasNewer reports another page', async () => {
const { instance, statusWatchQuery, logsWatchQuery } = buildInstance(['DEPLOYED']);

await instance.deploymentLogs();
statusWatchQuery.emit({ data: { Deployment: { status: 'DEPLOYED' } } });
await logsWatchQuery.emit({
data: { getDeploymentLogsV2: page([{ message: 'a', timestamp: 'x' }], { hasNewer: true, newestCursor: 'c1' }) },
});

expect(logsWatchQuery.stopPolling).not.toHaveBeenCalled();

await logsWatchQuery.emit({
data: { getDeploymentLogsV2: page([{ message: 'b', timestamp: 'y' }], { hasNewer: false, newestCursor: 'c2' }) },
});

expect(logsWatchQuery.stopPolling).toHaveBeenCalledTimes(1);
});

it('falls back to the legacy getLogs query when the region has no V2 field', async () => {
const { instance, logsWatchQuery, fallbackWatchQuery, logsClientWatchQuery } = buildInstance();
const errors: any[] = [];
(instance as any).$event.on('deployment-logs', (e: any) => {
if (e.msgType === 'error') errors.push(e.message);
});

await instance.deploymentLogs();
await logsWatchQuery.emit({
data: null,
error: { message: 'Cannot query field "getDeploymentLogsV2" on type "Query".' },
});

expect(logsWatchQuery.stopPolling).toHaveBeenCalledTimes(1);
expect(logsClientWatchQuery).toHaveBeenCalledTimes(2);
expect(logsClientWatchQuery.mock.calls[1][0].variables).toEqual({ deploymentUid: 'd1' });
expect(fallbackWatchQuery.subscribe).toHaveBeenCalledTimes(1);
expect(errors).toHaveLength(0);
});

it('does not demote to the legacy query on a transient network error', async () => {
const { instance, logsWatchQuery, logsClientWatchQuery } = buildInstance();
const errors: any[] = [];
(instance as any).$event.on('deployment-logs', (e: any) => {
if (e.msgType === 'error') errors.push(e.message);
});

await instance.deploymentLogs();
await logsWatchQuery.emit({ data: null, error: { message: 'Failed to fetch' } });

expect(logsClientWatchQuery).toHaveBeenCalledTimes(1);
expect(errors).toContain('Failed to fetch');
});
});
156 changes: 155 additions & 1 deletion src/util/logs-polling-utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@ import { ApolloClient, ObservableQuery } from '@apollo/client/core';
import { Ora } from 'ora';

import { LogPollingInput, ConfigType } from '../types';
import { deploymentQuery, deploymentLogsQuery, serverlessLogsQuery } from '../graphql';
import {
deploymentQuery,
deploymentLogsQuery,
deploymentLogsV2Query,
serverlessLogsQuery,
} from '../graphql';
import { setTimeout as sleep } from 'timers/promises';
import { isNotDevelopment } from './apollo-client';

const requireApolloDeprecation = createRequire(__filename);

export default class LogPolling {
private static readonly DEPLOYMENT_LOGS_PAGE_SIZE = 5_000;

private static readonly V2_UNSUPPORTED_PATTERN =
/cannot query field ["'`]?getDeploymentLogsV2|unknown type ["'`]?DeploymentLogsV2QueryInput/i;

private config: ConfigType;
private $event!: EventEmitter;
private apolloLogsClient!: ApolloClient<any>;
Expand All @@ -20,6 +30,8 @@ export default class LogPolling {
public startTime!: number;
public endTime!: number;
public loader!: Ora | void;
private deploymentLogsCursor: string | null = null;
private deploymentLogsV1FallbackStarted = false;

constructor(params: LogPollingInput) {
const { apolloLogsClient, apolloManageClient, config, $event } = params;
Expand Down Expand Up @@ -159,6 +171,63 @@ export default class LogPolling {
statusWatchQuery.stopPolling();
}
});
const logsWatchQuery = this.withDeprecationsDisabled(() => {
return this.apolloLogsClient.watchQuery({
fetchPolicy: 'network-only',
query: deploymentLogsV2Query,
variables: {
query: this.deploymentLogsV2Variables(),
},
pollInterval: this.config.pollingInterval,
errorPolicy: 'all',
});
});
this.subscribeDeploymentLogsV2(logsWatchQuery);
}

/**
* @method deploymentLogsV2Variables - build the getDeploymentLogsV2 query input
*
* @return {*} {Record<string, unknown>}
* @memberof LogPolling
*/
private deploymentLogsV2Variables(): Record<string, unknown> {
return {
deploymentUid: this.config.deployment,
limit: LogPolling.DEPLOYMENT_LOGS_PAGE_SIZE,
sortDirection: this.deploymentLogsCursor ? 'asc' : 'desc',
...(this.deploymentLogsCursor ? { cursor: this.deploymentLogsCursor } : {}),
};
}

/**
* @method isUnsupportedQueryError - detect a logs service with no getDeploymentLogsV2
*
* @return {*} {boolean}
* @memberof LogPolling
*/
private isUnsupportedQueryError(error: any, errors?: readonly any[] | null): boolean {
const messages: string[] = [];
if (error?.message) messages.push(error.message);
for (const graphQLError of error?.graphQLErrors ?? []) {
if (graphQLError?.message) messages.push(graphQLError.message);
}
for (const graphQLError of errors ?? []) {
if (graphQLError?.message) messages.push(graphQLError.message);
}
return messages.some((message) => LogPolling.V2_UNSUPPORTED_PATTERN.test(message));
}

/**
* @method fallBackToDeploymentLogsV1 - re-poll through the legacy getLogs query
*
* @return {*} {void}
* @memberof LogPolling
*/
private fallBackToDeploymentLogsV1(): void {
if (this.deploymentLogsV1FallbackStarted) return;
this.deploymentLogsV1FallbackStarted = true;

const logsWatchQuery = this.withDeprecationsDisabled(() => {
return this.apolloLogsClient.watchQuery({
fetchPolicy: 'network-only',
Expand All @@ -173,6 +242,91 @@ export default class LogPolling {
this.subscribeDeploymentLogs(logsWatchQuery);
}

/**
* @method subscribeDeploymentLogsV2 - subscribe cursor-paged deployment logs
*
* @return {*} {void}
* @memberof LogPolling
*/
subscribeDeploymentLogsV2(
logsWatchQuery: ObservableQuery<
any,
{
query: Record<string, unknown>;
}
>,
): void {
logsWatchQuery.subscribe(async({ data, errors, error }) => {
if(!this.loader){
this.loader = cliux.loaderV2('Loading deployment logs...');
}
if (this.isUnsupportedQueryError(error, errors)) {
logsWatchQuery.stopPolling();
this.fallBackToDeploymentLogsV1();
return;
}
if (error) {
this.loader=cliux.loaderV2('done', this.loader);
this.$event.emit('deployment-logs', {
message: error?.message,
msgType: 'error',
});
this.$event.emit('deployment-logs', {
message: 'DONE',
msgType: 'debug',
});
logsWatchQuery.stopPolling();
}
Comment thread
AryanBansal-launch marked this conversation as resolved.
if (errors?.length && data === null) {
this.loader=cliux.loaderV2('done', this.loader);
this.$event.emit('deployment-logs', {
message: errors,
msgType: 'error',
});
this.$event.emit('deployment-logs', {
message: 'DONE',
msgType: 'debug',
});
logsWatchQuery.stopPolling();
}
if (this.deploymentStatus) {
const page = data?.getDeploymentLogsV2;
const logsData = page?.logs;
const hasNewer = page?.pageInfo?.hasNewer === true;
let advanced = false;

if (logsData?.length) {
this.loader=cliux.loaderV2('done', this.loader);
this.$event.emit('deployment-logs', {
message: logsData,
msgType: 'info',
});

const nextCursor = page?.pageInfo?.newestCursor;
if (nextCursor && nextCursor !== this.deploymentLogsCursor) {
this.deploymentLogsCursor = nextCursor;
advanced = true;
logsWatchQuery.setVariables({
query: this.deploymentLogsV2Variables(),
} as any);
}
}

if (this.config.deploymentStatus.includes(this.deploymentStatus) && !(hasNewer && advanced)) {
await sleep(1_000);
logsWatchQuery.stopPolling();
this.$event.emit('deployment-logs', {
message: 'DONE',
msgType: 'debug',
});
if(this.loader){
this.loader=cliux.loaderV2('done', this.loader);
}
}
}
});
}

/**
* @method subscribeDeploymentLogs - subscribe deployment logs
*
Expand Down
Loading