Add Agent status action (#159227)

Closes https://github.com/elastic/kibana/issues/159226

## Summary

Adds the Agent Status Action at the bottom of the Serverless
Instructions. This status check basically checks for data present in ES
Index

- [x] PR adds Agent Status Check using the existing API for `hasData`
- [x] Add E2E tests for Serverless Onboarding

## Demo

### When No data is present


fcd96782-5aa9-4c1b-915f-1f506da35fe3

### When data is present


0c625ba9-67dc-4e66-87f2-982809c1ca72

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Achyut Jhunjhunwala 2023-06-19 10:40:04 +02:00 committed by GitHub
parent c8e8439554
commit 5cb887ec89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 459 additions and 18 deletions

View file

@ -0,0 +1,167 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { apm, timerange } from '@kbn/apm-synthtrace-client';
import { synthtrace } from '../../../../synthtrace';
const start = Date.now() - 1000;
const end = Date.now();
function generateData({ from, to }: { from: number; to: number }) {
const range = timerange(from, to);
const synthGo1 = apm
.service({
name: 'synth-go-1',
environment: 'production',
agentName: 'go',
})
.instance('my-instance');
return range.interval('1m').generator((timestamp) => {
return [
synthGo1
.transaction({ transactionName: 'GET /apple 🍎' })
.timestamp(timestamp)
.duration(1000)
.success(),
];
});
}
describe('APM Onboarding', () => {
describe('General navigation', () => {
beforeEach(() => {
cy.loginAsEditorUser();
cy.visitKibana('/app/apm/onboarding');
});
it('includes section for APM Agents', () => {
cy.contains('APM Agents');
cy.contains('Node.js');
cy.contains('Django');
cy.contains('Flask');
cy.contains('Ruby on Rails');
cy.contains('Rack');
cy.contains('Go');
cy.contains('Java');
cy.contains('.NET');
cy.contains('PHP');
cy.contains('OpenTelemetry');
});
it('navigation to different Tabs', () => {
cy.contains('Django').click();
cy.contains('pip install elastic-apm');
cy.contains('Flask').click();
cy.contains('pip install elastic-apm[flask]');
cy.contains('Ruby on Rails').click();
cy.contains("gem 'elastic-apm'");
cy.contains('Rack').click();
cy.contains("gem 'elastic-apm'");
cy.contains('Go').click();
cy.contains('go get go.elastic.co/apm');
cy.contains('Java').click();
cy.contains('-javaagent');
cy.contains('.NET').click();
cy.contains('Elastic.Apm.NetCoreAll');
cy.contains('PHP').click();
cy.contains('apk add --allow-untrusted <package-file>.apk');
cy.contains('OpenTelemetry').click();
cy.contains('Download the OpenTelemetry APM Agent or SDK');
});
});
describe('check Agent Status', () => {
beforeEach(() => {
cy.loginAsEditorUser();
cy.visitKibana('/app/apm/onboarding');
});
it('when no data is present', () => {
cy.intercept('GET', '/internal/apm/observability_overview/has_data').as(
'hasData'
);
cy.getByTestSubj('checkAgentStatus').click();
cy.wait('@hasData');
cy.getByTestSubj('agentStatusWarningCallout').should('exist');
});
it('when data is present', () => {
synthtrace.index(
generateData({
from: new Date(start).getTime(),
to: new Date(end).getTime(),
})
);
cy.intercept('GET', '/internal/apm/observability_overview/has_data').as(
'hasData'
);
cy.getByTestSubj('checkAgentStatus').click();
cy.wait('@hasData');
cy.getByTestSubj('agentStatusSuccessCallout').should('exist');
synthtrace.clean();
});
});
describe('create API Key', () => {
it('create the key successfully', () => {
cy.loginAsApmManageOwnAndCreateAgentKeys();
cy.visitKibana('/app/apm/onboarding');
cy.intercept('POST', '/api/apm/agent_keys').as('createApiKey');
cy.getByTestSubj('createApiKeyAndId').click();
cy.wait('@createApiKey');
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('Django').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('Flask').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('Ruby on Rails').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('Rack').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('Go').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('Java').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('.NET').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('PHP').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
cy.contains('OpenTelemetry').click();
cy.getByTestSubj('apiKeySuccessCallout').should('exist');
});
it('fails to create the key due to missing privileges', () => {
cy.loginAsEditorUser();
cy.visitKibana('/app/apm/onboarding');
cy.intercept('POST', '/api/apm/agent_keys').as('createApiKey');
cy.getByTestSubj('createApiKeyAndId').click();
cy.wait('@createApiKey');
cy.getByTestSubj('apiKeyWarningCallout').should('exist');
cy.get('@createApiKey')
.its('response')
.then((res) => {
expect(res.statusCode).to.equal(403);
});
});
});
});

View file

@ -26,6 +26,13 @@ Cypress.Commands.add('loginAsMonitorUser', () => {
});
});
Cypress.Commands.add('loginAsApmManageOwnAndCreateAgentKeys', () => {
return cy.loginAs({
username: ApmUsername.apmManageOwnAndCreateAgentKeys,
password: 'changeme',
});
});
Cypress.Commands.add(
'loginAs',
({ username, password }: { username: string; password: string }) => {

View file

@ -10,6 +10,9 @@ declare namespace Cypress {
loginAsViewerUser(): Cypress.Chainable<Cypress.Response<any>>;
loginAsEditorUser(): Cypress.Chainable<Cypress.Response<any>>;
loginAsMonitorUser(): Cypress.Chainable<Cypress.Response<any>>;
loginAsApmManageOwnAndCreateAgentKeys(): Cypress.Chainable<
Cypress.Response<any>
>;
loginAs(params: {
username: string;
password: string;

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiCallOut,
EuiMarkdownFormat,
EuiSpacer,
} from '@elastic/eui';
import React from 'react';
import { EuiStepStatus } from '@elastic/eui/src/components/steps/step_number';
function AgentStatusWarningCallout() {
return (
<EuiCallOut color="warning" data-test-subj="agentStatusWarningCallout">
{i18n.translate(
'xpack.apm.onboarding.agentStatus.warning.calloutMessage',
{
defaultMessage: 'No data has been received from agents yet',
}
)}
</EuiCallOut>
);
}
function AgentStatusSuccessCallout() {
return (
<EuiCallOut color="success" data-test-subj="agentStatusSuccessCallout">
{i18n.translate(
'xpack.apm.onboarding.agentStatus.success.calloutMessage',
{
defaultMessage: 'Data successfully received from one or more agents',
}
)}
</EuiCallOut>
);
}
export function agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}: {
checkAgentStatus: () => void;
agentStatus?: boolean;
agentStatusLoading: boolean;
}) {
let status: EuiStepStatus = 'incomplete';
let statusCallout = <></>;
// Explicit false check required as this value can be null initially. API returns true/false based on data present
if (agentStatus === false) {
status = 'warning';
statusCallout = <AgentStatusWarningCallout />;
}
if (agentStatus) {
status = 'complete';
statusCallout = <AgentStatusSuccessCallout />;
}
return {
title: i18n.translate('xpack.apm.onboarding.agentStatusCheck.title', {
defaultMessage: 'Agent status',
}),
children: (
<>
<EuiMarkdownFormat>
{i18n.translate('xpack.apm.onboarding.agentStatusCheck.textPre', {
defaultMessage:
'Make sure your application is running and the agents are sending data.',
})}
</EuiMarkdownFormat>
<EuiSpacer />
<EuiButton
data-test-subj="checkAgentStatus"
onClick={checkAgentStatus}
isLoading={agentStatusLoading}
>
{i18n.translate('xpack.apm.onboarding.agentStatus.check', {
defaultMessage: 'Check Agent Status',
})}
</EuiButton>
<EuiSpacer />
{statusCallout}
</>
),
status,
};
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { EuiSpacer } from '@elastic/eui';
import { callApmApi } from '../../../services/rest/create_call_apm_api';
@ -25,6 +25,8 @@ export function Onboarding() {
error: false,
});
const [apiKeyLoading, setApiKeyLoading] = useState(false);
const [agentStatus, setAgentStatus] = useState<boolean>();
const [agentStatusLoading, setAgentStatusLoading] = useState(false);
const { services } = useKibana<ApmPluginStartDeps>();
const { config } = useApmPluginContext();
const { docLinks, observabilityShared } = services;
@ -34,7 +36,7 @@ export function Onboarding() {
const baseUrl = docLinks?.ELASTIC_WEBSITE_URL || 'https://www.elastic.co/';
const createAgentKey = useCallback(async () => {
const createAgentKey = async () => {
try {
setApiKeyLoading(true);
const privileges: PrivilegeType[] = [PrivilegeType.EVENT];
@ -55,8 +57,7 @@ export function Onboarding() {
);
setAgentApiKey({
apiKey: agentKey.api_key,
encodedKey: agentKey.encoded,
apiKey: agentKey.encoded,
id: agentKey.id,
error: false,
});
@ -69,7 +70,24 @@ export function Onboarding() {
} finally {
setApiKeyLoading(false);
}
}, []);
};
const checkAgentStatus = async () => {
try {
setAgentStatusLoading(true);
const agentStatusCheck = await callApmApi(
'GET /internal/apm/observability_overview/has_data',
{
signal: null,
}
);
setAgentStatus(agentStatusCheck.hasData);
} catch (error) {
setAgentStatus(false);
} finally {
setAgentStatusLoading(false);
}
};
const instructionsExists = instructions.length > 0;
@ -81,13 +99,23 @@ export function Onboarding() {
{
baseUrl,
config,
checkAgentStatus,
agentStatus,
agentStatusLoading,
},
apiKeyLoading,
agentApiKey,
createAgentKey
),
]);
}, [agentApiKey, baseUrl, config, createAgentKey, apiKeyLoading]);
}, [
agentApiKey,
baseUrl,
config,
apiKeyLoading,
agentStatus,
agentStatusLoading,
]);
const ObservabilityPageTemplate = observabilityShared.navigation.PageTemplate;
return (

View file

@ -50,7 +50,6 @@ export function getDisplayText(id: INSTRUCTION_VARIANT) {
export interface AgentApiKey {
apiKey: string | null;
id?: string;
encodedKey?: string;
error: boolean;
errorMessage?: string;
}
@ -67,4 +66,7 @@ export interface AgentInstructions {
apmServerUrl: string;
apiKeyDetails?: AgentApiDetails;
secretToken?: string;
checkAgentStatus: () => void;
agentStatus?: boolean;
agentStatusLoading: boolean;
}

View file

@ -30,6 +30,7 @@ export function ApiKeyCallout({
)}
color="success"
iconType="check"
data-test-subj="apiKeySuccessCallout"
>
{i18n.translate(
'xpack.apm.onboarding.apiKey.success.calloutMessage',
@ -57,6 +58,7 @@ export function ApiKeyCallout({
)}
color="warning"
iconType="warning"
data-test-subj="apiKeyWarningCallout"
>
{i18n.translate('xpack.apm.onboarding.apiKey.warning.calloutMessage', {
defaultMessage:
@ -76,6 +78,7 @@ export function ApiKeyCallout({
})}
color="danger"
iconType="error"
data-test-subj="apiKeyErrorCallout"
>
{i18n.translate('xpack.apm.onboarding.apiKey.error.calloutMessage', {
defaultMessage: 'Error: {errorMessage}',

View file

@ -15,11 +15,19 @@ import {
AgentInstructions,
} from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createDjangoAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
return [
{
title: i18n.translate('xpack.apm.onboarding.django.install.title', {
@ -86,5 +94,10 @@ APM services are created programmatically based on the `SERVICE_NAME`.',
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -16,11 +16,19 @@ import {
AgentInstructions,
} from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createDotNetAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
const codeBlock = `public class Startup
{
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
@ -147,5 +155,10 @@ export const createDotNetAgentInstructions = (
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -15,11 +15,19 @@ import {
AgentInstructions,
} from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createFlaskAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
return [
{
title: i18n.translate('xpack.apm.onboarding.flask.install.title', {
@ -86,5 +94,10 @@ APM services are created programmatically based on the `SERVICE_NAME`.',
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -15,11 +15,19 @@ import {
AgentInstructions,
} from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createGoAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
const codeBlock = `\
import (
"net/http"
@ -130,5 +138,10 @@ guide to instrumenting Go source code.',
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -15,11 +15,19 @@ import {
AgentInstructions,
} from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createJavaAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
return [
{
title: i18n.translate('xpack.apm.onboarding.java.download.title', {
@ -100,5 +108,10 @@ export const createJavaAgentInstructions = (
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -15,11 +15,19 @@ import {
INSTRUCTION_VARIANT,
AgentInstructions,
} from '../instruction_variants';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createNodeAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
return [
{
title: i18n.translate('xpack.apm.onboarding.node.install.title', {
@ -89,5 +97,10 @@ export const createNodeAgentInstructions = (
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -21,11 +21,19 @@ import { ValuesType } from 'utility-types';
import { FormattedMessage } from '@kbn/i18n-react';
import { AgentApiDetails, AgentInstructions } from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createOpenTelemetryAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
return [
{
title: i18n.translate('xpack.apm.onboarding.otel.download.title', {
@ -96,6 +104,11 @@ export const createOpenTelemetryAgentInstructions = (
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};
@ -151,7 +164,7 @@ export function OpenTelemetryInstructions({
if (secretToken) {
authHeaderValue = `Authorization=Bearer ${secretToken}`;
} else {
authHeaderValue = `Authorization=ApiKey ${apiKeyDetails?.encodedKey}`;
authHeaderValue = `Authorization=ApiKey ${apiKeyDetails?.apiKey}`;
}
const items = [
{

View file

@ -15,11 +15,19 @@ import {
AgentInstructions,
} from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createPhpAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
return [
{
title: i18n.translate('xpack.apm.onboarding.php.download.title', {
@ -119,5 +127,10 @@ export const createPhpAgentInstructions = (
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -15,11 +15,19 @@ import {
AgentInstructions,
} from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createRackAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
const codeBlock = `# config.ru
require 'sinatra/base'
@ -130,5 +138,10 @@ export const createRackAgentInstructions = (
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -15,11 +15,19 @@ import {
AgentInstructions,
} from '../instruction_variants';
import { ApiKeyCallout } from './api_key_callout';
import { agentStatusCheckInstruction } from '../agent_status_instructions';
export const createRailsAgentInstructions = (
commonOptions: AgentInstructions
): EuiStepProps[] => {
const { baseUrl, apmServerUrl, apiKeyDetails } = commonOptions;
const {
baseUrl,
apmServerUrl,
apiKeyDetails,
checkAgentStatus,
agentStatus,
agentStatusLoading,
} = commonOptions;
return [
{
title: i18n.translate('xpack.apm.onboarding.rails.install.title', {
@ -85,5 +93,10 @@ export const createRailsAgentInstructions = (
</>
),
},
agentStatusCheckInstruction({
checkAgentStatus,
agentStatus,
agentStatusLoading,
}),
];
};

View file

@ -29,9 +29,15 @@ export function serverlessInstructions(
{
baseUrl,
config,
checkAgentStatus,
agentStatus,
agentStatusLoading,
}: {
baseUrl: string;
config: ConfigSchema;
checkAgentStatus: () => void;
agentStatus?: boolean;
agentStatusLoading: boolean;
},
apiKeyLoading: boolean,
apiKeyDetails: AgentApiKey,
@ -43,6 +49,9 @@ export function serverlessInstructions(
const commonOptions: AgentInstructions = {
baseUrl,
apmServerUrl: config.managedServiceUrl,
checkAgentStatus,
agentStatus,
agentStatusLoading,
apiKeyDetails: {
...apiKeyDetails,
displayApiKeySuccessCallout,