Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jwilaby/#1185 app insights #1192

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/app/client/src/data/sagas/servicesExplorerSagas.ts
Expand Up @@ -247,8 +247,7 @@ function* openAddConnectedServiceContextMenu(action: ConnectedServiceAction<Conn
const response = yield CommandServiceImpl.remoteCall(SharedConstants.Commands.Electron.DisplayContextMenu, menuItems);
const { id: serviceType } = response;
action.payload.serviceType = serviceType;
if (serviceType === ServiceTypes.Generic ||
serviceType === ServiceTypes.AppInsights) {
if (serviceType === ServiceTypes.Generic) {
yield* launchConnectedServiceEditor(action);
} else {
yield* launchConnectedServicePicker(action);
Expand Down
Expand Up @@ -134,6 +134,14 @@ describe('The GetStartedWithCSDialog component should', () => {
expect(prompt.instance().content).toEqual(prompt.instance().cosmosDbContent);
});

it('should display appInsightsContent when the ServiceTypes.AppInsights is provided in the props', () => {
const parent: any = mount(<Provider store={ mockStore }>
<GetStartedWithCSDialogContainer serviceType={ ServiceTypes.AppInsights }/>
</Provider>);
const prompt = parent.find(GetStartedWithCSDialog);
expect(prompt.instance().content).toEqual(prompt.instance().appInsightsContent);
});

it('should display no when no service type provided in the props', () => {
const parent: any = mount(<Provider store={ mockStore }>
<GetStartedWithCSDialogContainer/>
Expand Down
Expand Up @@ -64,6 +64,9 @@ export class GetStartedWithCSDialog extends Component<GetStartedWithCSDialogProp
case ServiceTypes.CosmosDB:
return this.cosmosDbContent;

case ServiceTypes.AppInsights:
return this.appInsightsContent;

default:
return null;
}
Expand Down Expand Up @@ -209,7 +212,7 @@ export class GetStartedWithCSDialog extends Component<GetStartedWithCSDialogProp
</p>
<p>
{ `You have do not have a Blob container under ${ this.props.authenticatedUser }. ` }
<a href="https://azure.microsoft.com/en-us/services/storage/blobs/">Get started with Blob Storage</a>
<a href="https://aka.ms/bot-framework-emulator-create-storage">Get started with Blob Storage</a>
</p>
<p>
{ ' Alternatively, you can ' }
Expand All @@ -230,7 +233,7 @@ export class GetStartedWithCSDialog extends Component<GetStartedWithCSDialogProp
</p>
<p>
{ `You have do not have any CosmosDB collections under ${ this.props.authenticatedUser }. ` }
<a href="https://azure.microsoft.com/en-us/services/cosmos-db/">Get started with CosmosDB</a>
<a href="https://aka.ms/bot-framework-emulator-create-storage">Get started with CosmosDB</a>
</p>
<p>
{ ' Alternatively, you can ' }
Expand All @@ -242,4 +245,26 @@ export class GetStartedWithCSDialog extends Component<GetStartedWithCSDialogProp
</>
);
}

private get appInsightsContent(): ReactNode {
return (
<>
<p>
{ 'Application Insights is an extensible Application Performance Management (APM) ' +
'service for web developers on multiple platforms. Use it to monitor your Azure Bot Service.' }
</p>
<p>
{ `You have do not have any Application Insights Components under ${ this.props.authenticatedUser }. ` }
<a href="https://aka.ms/bot-framework-emulator-create-appinsights">Get started with Application Insights</a>
</p>
<p>
{ ' Alternatively, you can ' }
<a href="javascript:void(0);" onClick={ this.props.launchConnectedServiceEditor }>
connect to an Application Insights Component manually
</a>
{ ' if you know the ID, subscription key, instrumentation and api key.' }
</p>
</>
);
}
}
Expand Up @@ -4,6 +4,7 @@
position: relative;
border: none;
&::before {
transition: width .2s ease;
content: '';
position: absolute;
height: 4px;
Expand Down
Expand Up @@ -371,7 +371,7 @@ export class ConnectedServicePicker extends Component<ConnectedServicesPickerPro
return (
<>
<a href="https://aka.ms/bot-framework-emulator-create-appinsights" className={ styles.paddedLink }>
Create a new Azure storage account
Create a new Application Insights Component
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conventionally this should evaluate a JS string, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean here. JSX treats text node children as strings so yes, in that sense it does evaluate to a JS string then used as a TextNode in the dom once rendered. Is this what you mean?

Copy link
Member

@cwhitten cwhitten Dec 20, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By that I mean most of the text rendered in the DOM is explicitly evaluated like this in the code:

{ 'Create a new Application Insights Component' }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bracket notation is only necessary when you need to preserve whitespace at the end of linebreaks in code. e.g.

<p>Create a new Application <- This space here will be lost when transpiling
Insights Component</p>

To fix this we use:

<p> { 'Create a new Application Insights '+ <- space is preserved
'Component' } </p>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, a lot of codebases would enforce brackets always for consistency. Once we move to internationalization this won't matter, as every string will be require it.

</a>
<p>
{ ` Signed in as ${ this.props.authenticatedUser }.` }
Expand Down
5 changes: 5 additions & 0 deletions packages/app/main/src/commands/connectedServiceCommands.ts
@@ -1,6 +1,7 @@
import { CommandRegistry } from '@bfemulator/sdk-shared';
import { IConnectedService, ServiceTypes } from 'botframework-config/lib/schema';
import { SharedConstants } from '@bfemulator/app-shared';
import { AppInsightsApiService } from '../services/appInsightsApiService';
import { CosmosDbApiService } from '../services/cosmosDbApiService';
import { StorageAccountApiService } from '../services/storageAccountApiService';
import { LuisApi } from '../services/luisApiService';
Expand Down Expand Up @@ -32,6 +33,10 @@ export function registerCommands(commandRegistry: CommandRegistry) {
it = CosmosDbApiService.getCosmosDbServices(armToken);
break;

case ServiceTypes.AppInsights:
it = AppInsightsApiService.getAppInsightsServices(armToken);
break;

default:
throw new TypeError(`The ServiceTypes ${serviceType} is not a known service type`);
}
Expand Down
160 changes: 160 additions & 0 deletions packages/app/main/src/services/appInsightsApiService.spec.ts
@@ -0,0 +1,160 @@
import '../fetchProxy';
import { AppInsightsApiService } from './appInsightsApiService';

const mockArmToken = 'bm90aGluZw.eyJ1cG4iOiJnbGFzZ293QHNjb3RsYW5kLmNvbSJ9.7gjdshgfdsk98458205jfds9843fjds';
let mockArgsPassedToFetch = [];
let mockResponses;
jest.mock('node-fetch', () => {
const fetch = (url, headers) => {
mockArgsPassedToFetch.push({ url, headers });
return {
ok: true,
json: async () => mockResponses.shift(),
text: async () => mockResponses.shift()
};
};
(fetch as any).Headers = class {
};
(fetch as any).Response = class {
};
return fetch;
});

const mockResponseTemplate = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a notion of a fixtures directory in the emulator codebase? It would be nice to have this exportable and possibly held somewhere general for re-use, if it's reasonable to possibly need it again in a different test file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not have fixtures in this codebase since there isn't a lot of opportunity to reuse mock data other than in the test it applies to. In this case, these are specific to the test suite and cannot be reused (just the variable name is reused).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW I still think it's plenty valuable to remove this type of data from the test file for readability if nothing else. Plus it gives other developers a place to look to see if they're working on some shape that may be used already.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, should I do this for all the others (~25+)so we can be consistent throughout the app?

{
// Subscriptions
value: [
{
'id': '/subscriptions/1234',
'subscriptionId': '1234',
'tenantId': '1234',
'displayName': 'Pretty nice Service',
'state': 'Enabled',
'subscriptionPolicies': {
'locationPlacementId': 'Public_2014-09-01',
'quotaId': 'MSDN_2014-09-01',
'spendingLimit': 'On'
},
'authorizationSource': 'Legacy'
},
{
'id': '/subscriptions/1234',
'subscriptionId': '1234',
'tenantId': '43211',
'displayName': 'A useful service',
'state': 'Enabled',
'subscriptionPolicies': {
'locationPlacementId': 'Internal_2014-09-01',
'quotaId': 'Internal_2014-09-01',
'spendingLimit': 'Off'
},
'authorizationSource': 'RoleBased'
}
]
},
// Components
{
value: [
{
'id': '/subscriptions/08a9411c/resourceGroups/myResourceGroup/' +
'providers/microsoft.insights/components/TestAppInsights',
'name': 'TestAppInsights',
'type': 'microsoft.insights/components',
'location': 'westus2',
'kind': 'Node.JS',
'etag': '0000',
'properties': {
'ApplicationId': 'TestAppInsights',
'AppId': '70f773ca',
'Application_Type': 'Node.JS',
'Flow_Type': 'Redfield',
'Request_Source': 'IbizaAIExtension',
'InstrumentationKey': '2e1f4ec2',
'Name': 'TestAppInsights',
'CreationDate': '2018-11-20T17:29:18.0789365+00:00',
'PackageId': null,
'TenantId': '08a9411c',
}
}
]
},
{
value: [
{
'id': '/subscriptions/08a9411c/resourceGroups/myResourceGroup2/' +
'providers/microsoft.insights/components/TestAppInsights',
'name': 'TestAppInsights',
'type': 'microsoft.insights/components',
'location': 'westus2',
'kind': 'Node.JS',
'etag': '0000',
'properties': {
'ApplicationId': 'TestAppInsights',
'AppId': '70f773ca2',
'Application_Type': 'Node.JS',
'Flow_Type': 'Redfield',
'Request_Source': 'IbizaAIExtension',
'InstrumentationKey': '2e1f4ec22',
'Name': 'TestAppInsights2',
'CreationDate': '2018-11-20T17:29:18.0789365+00:00',
'PackageId': null,
'TenantId': '08a9411c2',
}
}
]
},
// api-keys
{
value: [
{ id: '/subscriptions/1234/resourcegroups/container/providers/microsoft.insights/components/com/apikeys/53434' }
]
},
{
value: [
{ id: '/subscriptions/123456/resourcegroups/container/providers/microsoft.insights/components/com/apikeys/4532' }
]
}
];

describe('The AppInsightsApiService', () => {
beforeEach(() => {
mockResponses = JSON.parse(JSON.stringify(mockResponseTemplate));
mockArgsPassedToFetch.length = 0;
});

it('should deliver AppInsightsService objects when the happy path is followed', async () => {
const result = await getResult();
expect(result.services.length).toBe(2);
expect(result.code).toBe(0);
});

it('should return an empty payload with an error if no subscriptions are found', async () => {
mockResponses = [{ value: [] }];
const result = await getResult();
expect(result).toEqual({ services: [], code: 1 });
});

it('should return an empty payload with an error if no components are found', async () => {
mockResponses[1] = mockResponses[2] = { value: [] };
const result = await getResult();
expect(result).toEqual({ services: [], code: 1 });
});
});

async function getResult() {
const it = AppInsightsApiService.getAppInsightsServices(mockArmToken);
let result = undefined;
while (true) {
const next = it.next(result);
if (next.done) {
result = next.value;
break;
}
try {
result = await next.value;
} catch (e) {
break;
}
}
return result;
}
114 changes: 114 additions & 0 deletions packages/app/main/src/services/appInsightsApiService.ts
@@ -0,0 +1,114 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license.
//
// Microsoft Bot Framework: http://botframework.com
//
// Bot Framework Emulator Github:
// https://github.com/Microsoft/BotFramwork-Emulator
//
// Copyright (c) Microsoft Corporation
// All rights reserved.
//
// MIT License:
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
import { ServiceCodes } from '@bfemulator/app-shared';
import { AppInsightsService } from 'botframework-config/lib/models';
import { AzureManagementApiService, AzureResource, baseUrl, Provider, Subscription } from './azureManagementApiService';

export class AppInsightsApiService {

public static* getAppInsightsServices(armToken: string): IterableIterator<any> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function is doing quite a lot. can it be factored into smaller pieces? the way you've numbered the sections of code could be a good place to start!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, I thought about it but the function signatures would have a lot of repetitive args and would make debugging more difficult since I would have to yield to another generator. I'm not sure there is a lot to gain from breaking it out into smaller functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another note is that we are in the process of evaluating how this data is retrieved. It could change on the next iteration to include a hierarchical data structure to show the relationship between Subscription, Resource Group and Finally the Resource itself.

Copy link
Member

@cwhitten cwhitten Dec 20, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, it smells to me. I would think that would be more reason to be thoughtful about the separation of concerns, to not throw it all away if there is a change is relation or organization. Why can't this be broken into the most reasonable and smallest unit of work, so that down the road it can very easily be recombined or factored without much churn in the live code or the test code?

Tangentially but I forgot to ask, what happens if any of the network activity in the parts that need it fails?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusable work units are sufficiently broken out in the AzureManagementApiService class. Each of the composed services provide a concrete implementation that aggregates various data to build the models that are delivered once the generator completes. As we iterate on design and a new use case requires a different approach, refactoring is trivial and a low LOE.

const payload = { services: [], code: ServiceCodes.OK };

// 1. get a list of subscriptions for the user
yield { label: 'Retrieving subscriptions from Azure…', progress: 25 };
const subs: Subscription[] = yield AzureManagementApiService.getSubscriptions(armToken);
if (!subs) {
payload.code = ServiceCodes.AccountNotFound;
return payload;
}

// 2. Retrieve the app insights components
yield { label: 'Retrieving Application Insights Components from Azure…', progress: 50 };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these yielded only for the tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take a look at the consumer of this generator. Those yields are to indicate progress.

Copy link
Member

@cwhitten cwhitten Dec 20, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Is it common for a service like this to have presentational opinions on the state it emits? I could see a better pattern something like emitting status codes that the caller can respond to. This gives more control over the view to the caller.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So instead of emitting text, emit status codes that map to text in the consumer? Seems like an extra step that moves the responsibility of interpreting state onto the consumer. The idea here is to keep the consumer simple and dumb so it can manage any number of iterators. The service then is burdened with managing its own state as yielded output from the generator.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A scenario off to top of my head would be if the view needed to special case the label for any reason based off of some state that the view has (or knowledge of the user, their locale/language settings, etc).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these use cases do not yet exist and the technical implementation isn't clear. Let's keep this as-is and revisit it once we have a strategy for localization. I'm skeptical about adding infrastructure when the value of doing so depends on a yet-to-be defined requirement. Let's define the requirement, then refactor to fit.

const req = AzureManagementApiService.getRequestInit(armToken);
const requests = subs.map(sub => {
const url = `${ baseUrl + sub.id }/providers/${ Provider.ApplicationInsights }/components?api-version=2015-05-01`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

${ baseUrl + sub.id }/providers/${ Provider.ApplicationInsights }/${AccountIdentifier.ApplicationInsights} ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Different API versions perform different actions on the same endpoint. It is possible that 2 different API versions can be used on the same endpoint yielding different results so I am keeping them within the concrete implementation instead of an enumeration.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"components?api-version=2015-05-01" is the value currently assigned to AccountIdentifier.ApplicationInsights. You'd prefer to duplicate and hardcode it here?

return fetch(url, req);
});
const appInsightsResponses: Response[] = yield Promise.all(requests);
const appInsightsComponents: { component: AzureResource, subscription: Subscription }[] = [];
let i = appInsightsResponses.length;
while (i--) {
const response = appInsightsResponses[i];
if (!response.ok) {
continue;
}
const { value: components = [] }: { value: AzureResource[] } = yield response.json();
const subscription = subs[i];
components.forEach(component => appInsightsComponents.push({ component, subscription }));
}

if (!appInsightsComponents.length) {
payload.code = ServiceCodes.AccountNotFound;
return payload;
}

// 3. Retrieve the api-keys for each component
yield { label: 'Retrieving Api Keys from Azure…', progress: 75 };
const apiKeysRequests = appInsightsComponents.map(info => {
const { component } = info;
return fetch(`${ baseUrl + component.id }/apiKeys?api-version=2015-05-01`, req);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The apiKeys endpoint is not an AccountIdentifier since it does not return account related information.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a new enum be appropriate next to this one holding this ${slug}?${version} pair?

});
const apiKeysResponses: Response[] = yield Promise.all(apiKeysRequests);
i = apiKeysResponses.length;
while (i--) {
const apiKeyResponse = apiKeysResponses[i];
const { value: apiKeyInfos = [] } = yield apiKeyResponse.json();
const { component, subscription } = appInsightsComponents[i];
if (!apiKeyInfos.length) {
continue;
}
// The id field contains the apiKey in a url
// that needs to be extracted
const apiKeys = apiKeyInfos.map((keyInfo: { id: string }) => {
const parts = keyInfo.id.split('/');
return parts[parts.length - 1];
});
payload.services.push(createAppInsightsService(component, subscription, apiKeys));
}
return payload;
}
}

function createAppInsightsService(component: AzureResource, sub: Subscription, keys: string[]): AppInsightsService {
const { id, name, properties } = component;
const { InstrumentationKey, ApplicationId, TenantId } = properties;
const service = new AppInsightsService();
service.id = id;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of mutating AppInsightsService's prototype after initialization here would it make more sense to pass this data into the constructor and giving it a type?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either way works. I find this to be cleaner and easier to read versus:

new AppInsightsService( {
  id,
  name,
  serviceName: name,
  tenantId: TenantId,
  // ...
}}

But this is generally preference IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the changes here for consistency.

service.name = service.serviceName = name;
service.instrumentationKey = InstrumentationKey;
service.tenantId = TenantId;
service.resourceGroup = id.split('/')[4];
service.apiKeys = keys.reduce((map, key, index) => (map[`key${ index }`] = key, map), {});
service.applicationId = ApplicationId;
return service;
}