Skip to content

Commit 4950c77

Browse files
committed
telemetry: AWS filetypes
Problem: No telemetry for AWS filetypes. Solution: - Set an `onDidOpenTextDocument` handler - Checks if the opened file is an "AWS filetype" by asking these services: - `schemaService` - `templateRegistry` - `awsFiletypes` - TODO: only if edited? (`onDidChangeTextDocument`) - TODO:is it worth adding "Resources" filetypes? - Emit the `file_editAwsFile` metric. - Currently "passive". May change later. - Add TelemetryLogger.clear() so that tests can clear the global logger state before querying. - TODO: can we eliminate TelemetryLogger (just use the TelemetryService directly)?
1 parent d06c1e6 commit 4950c77

File tree

14 files changed

+382
-47
lines changed

14 files changed

+382
-47
lines changed

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2938,7 +2938,7 @@
29382938
"report": "nyc report --reporter=html --reporter=json"
29392939
},
29402940
"devDependencies": {
2941-
"@aws-toolkits/telemetry": "1.0.42",
2941+
"@aws-toolkits/telemetry": "1.0.49",
29422942
"@sinonjs/fake-timers": "^8.1.0",
29432943
"@types/adm-zip": "^0.4.34",
29442944
"@types/async-lock": "^1.1.3",

src/dynamicResources/awsResourceManager.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,15 @@ export class AwsResourceManager {
197197
}
198198
}
199199

200-
globals.schemaService.registerMapping({
201-
path: file.fsPath,
202-
type: 'json',
203-
schema: location,
204-
})
200+
globals.schemaService.registerMapping(
201+
{
202+
path: file.fsPath,
203+
type: 'json',
204+
schema: location,
205+
},
206+
// Flush immediately so the onDidOpenTextDocument handler can work.
207+
true
208+
)
205209
}
206210
}
207211

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { activate as activateEcr } from './ecr/activation'
4141
import { activate as activateSam } from './shared/sam/activation'
4242
import { activate as activateTelemetry } from './shared/telemetry/activation'
4343
import { activate as activateS3 } from './s3/activation'
44+
import * as awsFiletypes from './shared/awsFiletypes'
4445
import * as telemetry from './shared/telemetry/telemetry'
4546
import { ExtContext } from './shared/extensions'
4647
import { activate as activateApiGateway } from './apigateway/activation'
@@ -118,6 +119,7 @@ export async function activate(context: vscode.ExtensionContext) {
118119
await activateTelemetry(context, awsContext, settings)
119120
await globals.telemetry.start()
120121
await globals.schemaService.start()
122+
awsFiletypes.activate()
121123

122124
const extContext: ExtContext = {
123125
extensionContext: context,

src/shared/awsFiletypes.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*!
2+
* Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import * as path from 'path'
8+
import * as constants from '../shared/constants'
9+
import * as aslFormats from '../stepFunctions/constants/aslFormats'
10+
import * as telemetry from './telemetry/telemetry'
11+
import * as pathutil from '../shared/utilities/pathUtils'
12+
import * as fsutil from '../shared/filesystemUtilities'
13+
import * as sysutil from '../shared/systemUtilities'
14+
import * as collectionUtil from '../shared/utilities/collectionUtils'
15+
import globals from './extensionGlobals'
16+
17+
/** AWS filetypes: vscode language ids */
18+
export const awsFiletypeLangIds = {
19+
/** vscode language ids registered by AWS Toolkit or other AWS extensions for handling. */
20+
awsOwned: [aslFormats.JSON_ASL, aslFormats.YAML_ASL, constants.ssmJson, constants.ssmYaml],
21+
/** generic vscode language ids that possibly are AWS filetypes */
22+
ambiguous: ['ini', 'plaintext', aslFormats.JSON_TYPE, aslFormats.YAML_TYPE],
23+
}
24+
25+
/**
26+
* Maps vscode language ids to AWS filetypes for telemetry.
27+
*/
28+
export function langidToAwsFiletype(langId: string): telemetry.AwsFiletype {
29+
switch (langId) {
30+
case constants.ssmJson:
31+
case constants.ssmYaml:
32+
return 'ssmDocument'
33+
case aslFormats.JSON_ASL:
34+
case aslFormats.YAML_ASL:
35+
return 'stepfunctionsAsl'
36+
default:
37+
return 'other'
38+
}
39+
}
40+
41+
/** Returns true if file `f` is somewhere in `~/.aws`. */
42+
export function isAwsConfig(f: string): boolean {
43+
const awsDir = path.join(sysutil.SystemUtilities.getHomeDirectory(), '.aws')
44+
if (fsutil.isInDirectory(awsDir, f)) {
45+
return true
46+
}
47+
return false
48+
}
49+
50+
export function isAwsFiletype(doc: vscode.TextDocument): boolean | undefined {
51+
if (awsFiletypeLangIds.awsOwned.includes(doc.languageId)) {
52+
return true
53+
}
54+
if (isAwsConfig(doc.fileName)) {
55+
return true
56+
}
57+
if (awsFiletypeLangIds.ambiguous.includes(doc.languageId)) {
58+
return undefined // Maybe
59+
}
60+
return false
61+
}
62+
63+
export function activate(): void {
64+
globals.context.subscriptions.push(
65+
// TODO: onDidChangeTextDocument ?
66+
vscode.workspace.onDidOpenTextDocument(async (doc: vscode.TextDocument) => {
67+
const isAwsFileExt = isAwsFiletype(doc)
68+
const isSchemaHandled = globals.schemaService.isMapped(doc.uri)
69+
const isCfnTemplate = !!globals.templateRegistry.registeredItems.find(
70+
t => pathutil.normalize(t.path) === pathutil.normalize(doc.fileName)
71+
)
72+
if (!isAwsFileExt && !isSchemaHandled && !isCfnTemplate) {
73+
return
74+
}
75+
76+
let fileExt: string | undefined = path.extname(doc.fileName)
77+
fileExt = fileExt ? fileExt : undefined // Telemetry client will fail on empty string.
78+
79+
// TODO: ask templateRegistry if this is SAM or CFN.
80+
// TODO: ask schemaService for the precise filetype.
81+
let telemKind = isAwsConfig(doc.fileName) ? 'awsCredentials' : langidToAwsFiletype(doc.languageId)
82+
if (telemKind === 'other') {
83+
telemKind = isCfnTemplate ? 'cloudformationSam' : isSchemaHandled ? 'cloudformation' : 'other'
84+
}
85+
86+
// HACK: for "~/.aws/foo" vscode sometimes _only_ emits "~/.aws/foo.git".
87+
if (telemKind === 'awsCredentials' && fileExt === '.git') {
88+
fileExt = undefined
89+
}
90+
91+
if (await isSameMetricPending(telemKind, fileExt)) {
92+
return // Avoid redundant/duplicate metrics.
93+
}
94+
95+
telemetry.recordFileEditAwsFile({
96+
awsFiletype: telemKind,
97+
passive: true,
98+
result: 'Succeeded',
99+
filenameExt: fileExt,
100+
})
101+
}, undefined)
102+
)
103+
}
104+
105+
async function isSameMetricPending(filetype: string, fileExt: string | undefined): Promise<boolean> {
106+
const pendingMetrics = await collectionUtil.first(
107+
globals.telemetry.findIter(m => {
108+
const m1 = m.Metadata?.find(o => o.Key === 'awsFiletype')
109+
const m2 = m.Metadata?.find(o => o.Key === 'filenameExt')
110+
return m.MetricName === 'file_editAwsFile' && m1?.Value === filetype && m2?.Value === fileExt
111+
})
112+
)
113+
return !!pendingMetrics
114+
}

src/shared/extensions/yaml.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function evaluate(schema: vscode.Uri | (() => vscode.Uri)): vscode.Uri {
3434
export interface YamlExtension {
3535
assignSchema(path: vscode.Uri, schema: vscode.Uri | (() => vscode.Uri)): void
3636
removeSchema(path: vscode.Uri): void
37+
getSchema(path: vscode.Uri): vscode.Uri | undefined
3738
}
3839

3940
export async function activateYamlExtension(): Promise<YamlExtension | undefined> {
@@ -61,5 +62,6 @@ export async function activateYamlExtension(): Promise<YamlExtension | undefined
6162
return {
6263
assignSchema: (path, schema) => schemaMap.set(path.toString(), applyScheme(AWS_SCHEME, evaluate(schema))),
6364
removeSchema: path => schemaMap.delete(path.toString()),
65+
getSchema: path => schemaMap.get(path.toString()),
6466
}
6567
}

src/shared/schemas.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as vscode from 'vscode'
99
import globals from './extensionGlobals'
1010
import { activateYamlExtension, YamlExtension } from './extensions/yaml'
1111
import * as filesystemUtilities from './filesystemUtilities'
12+
import * as pathutil from '../shared/utilities/pathUtils'
1213
import { getLogger } from './logger'
1314
import { FileResourceFetcher } from './resourcefetcher/fileResourceFetcher'
1415
import { getPropertyFromJsonUrl, HttpResourceFetcher } from './resourcefetcher/httpResourceFetcher'
@@ -29,7 +30,10 @@ export interface SchemaMapping {
2930
}
3031

3132
export interface SchemaHandler {
33+
/** Adds or removes a schema mapping to the given `schemas` collection. */
3234
handleUpdate(mapping: SchemaMapping, schemas: Schemas): Promise<void>
35+
/** Returns true if the given file path is handled by this `SchemaHandler`. */
36+
isMapped(f: vscode.Uri | string): boolean
3337
}
3438

3539
/**
@@ -64,13 +68,31 @@ export class SchemaService {
6468
])
6569
}
6670

71+
public isMapped(uri: vscode.Uri): boolean {
72+
for (const h of this.handlers.values()) {
73+
if (h.isMapped(uri)) {
74+
return true
75+
}
76+
}
77+
return false
78+
}
79+
6780
public async start(): Promise<void> {
6881
getDefaultSchemas(this.extensionContext).then(schemas => (this.schemas = schemas))
6982
await this.startTimer()
7083
}
7184

72-
public registerMapping(mapping: SchemaMapping): void {
85+
/**
86+
* Registers a schema mapping in the schema service.
87+
*
88+
* @param mapping
89+
* @param flush Flush immediately instead of waiting for timer.
90+
*/
91+
public registerMapping(mapping: SchemaMapping, flush?: boolean): void {
7392
this.updateQueue.push(mapping)
93+
if (flush === true) {
94+
this.processUpdates()
95+
}
7496
}
7597

7698
public async processUpdates(): Promise<void> {
@@ -259,6 +281,15 @@ async function addCustomTags(config = Settings.instance): Promise<void> {
259281
export class YamlSchemaHandler implements SchemaHandler {
260282
public constructor(private yamlExtension?: YamlExtension) {}
261283

284+
isMapped(file: string | vscode.Uri): boolean {
285+
if (!this.yamlExtension) {
286+
return false
287+
}
288+
const uri = typeof file === 'string' ? vscode.Uri.file(file) : file
289+
const exists = !!this.yamlExtension?.getSchema(uri)
290+
return exists
291+
}
292+
262293
async handleUpdate(mapping: SchemaMapping, schemas: Schemas): Promise<void> {
263294
if (!this.yamlExtension) {
264295
const ext = await activateYamlExtension()
@@ -286,14 +317,39 @@ export class JsonSchemaHandler implements SchemaHandler {
286317

287318
public constructor(private readonly config = Settings.instance) {}
288319

320+
public isMapped(file: string | vscode.Uri): boolean {
321+
const setting = this.getSettingBy({ file: file })
322+
return !!setting
323+
}
324+
325+
/**
326+
* Gets a json schema setting by filtering on schema path and/or file path.
327+
* @param args.schemaPath Path to the schema file
328+
* @param args.file Path to the file being edited by the user
329+
*/
330+
private getSettingBy(args: {
331+
schemaPath?: string | vscode.Uri
332+
file?: string | vscode.Uri
333+
}): JSONSchemaSettings | undefined {
334+
const path = typeof args.file === 'string' ? args.file : args.file?.fsPath
335+
const schm = typeof args.schemaPath === 'string' ? args.schemaPath : args.schemaPath?.fsPath
336+
const settings = this.getJsonSettings()
337+
const setting = settings.find(schema => {
338+
const schmMatch = schm && schema.url && pathutil.normalize(schema.url) === pathutil.normalize(schm)
339+
const fileMatch = path && schema.fileMatch && schema.fileMatch.includes(path)
340+
return (!path || fileMatch) && (!schm || schmMatch)
341+
})
342+
return setting
343+
}
344+
289345
async handleUpdate(mapping: SchemaMapping, schemas: Schemas): Promise<void> {
290346
await this.clean()
291347

292348
let settings = this.getJsonSettings()
293349

294350
if (mapping.schema) {
295351
const uri = resolveSchema(mapping.schema, schemas).toString()
296-
const existing = settings.find(schema => schema.url === uri)
352+
const existing = this.getSettingBy({ schemaPath: uri })
297353

298354
if (existing) {
299355
if (!existing.fileMatch) {

src/shared/telemetry/telemetryLogger.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class TelemetryLogger {
4545
return this._metrics.length
4646
}
4747

48+
public clear(): void {
49+
this._metrics.length = 0
50+
}
51+
4852
public log(metric: MetricDatum): void {
4953
const msg = `telemetry: emitted metric "${metric.MetricName}"`
5054
if (!isReleaseVersion()) {

src/shared/telemetry/telemetryService.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as path from 'path'
99
import { v4 as uuidv4 } from 'uuid'
1010
import { ExtensionContext } from 'vscode'
1111
import { AwsContext } from '../awsContext'
12-
import { isReleaseVersion } from '../vscode/env'
12+
import { isReleaseVersion, isAutomation } from '../vscode/env'
1313
import { getLogger } from '../logger'
1414
import { MetricDatum } from './clienttelemetry'
1515
import { DefaultTelemetryClient } from './telemetryClient'
@@ -88,11 +88,15 @@ export class DefaultTelemetryService {
8888
globals.clock.clearTimeout(this._timer)
8989
this._timer = undefined
9090
}
91-
const currTime = new globals.clock.Date()
92-
recordSessionEnd({ value: currTime.getTime() - this.startTime.getTime() })
9391

94-
// only write events to disk if telemetry is enabled at shutdown time
95-
if (this.telemetryEnabled) {
92+
// Only write events to disk at shutdown time if:
93+
// 1. telemetry is enabled
94+
// 2. we are not in CI or a test suite run
95+
if (this.telemetryEnabled && !isAutomation()) {
96+
const currTime = new globals.clock.Date()
97+
// This is noisy when running tests in vscode.
98+
recordSessionEnd({ value: currTime.getTime() - this.startTime.getTime() })
99+
96100
try {
97101
await writeFile(this.persistFilePath, JSON.stringify(this._eventQueue))
98102
} catch {}
@@ -302,6 +306,19 @@ export class DefaultTelemetryService {
302306
}
303307
}
304308
}
309+
310+
/**
311+
* Queries the current pending (not flushed) metrics.
312+
*
313+
* @note The underlying metrics queue may be updated or flushed at any time while this iterates.
314+
*/
315+
public async *findIter(predicate: (m: MetricDatum) => boolean): AsyncIterable<MetricDatum> {
316+
for (const m of this._eventQueue) {
317+
if (predicate(m)) {
318+
yield m
319+
}
320+
}
321+
}
305322
}
306323

307324
export function filterTelemetryCacheEvents(input: any): MetricDatum[] {

src/test/dynamicResources/awsResourceManager.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ describe('ResourceManager', function () {
185185

186186
it('registers schema mappings when opening in edit', async function () {
187187
const editor = await resourceManager.open(resourceNode, false)
188-
verify(schemaService.registerMapping(anything())).once()
188+
verify(schemaService.registerMapping(anything(), anything())).once()
189189

190190
// eslint-disable-next-line @typescript-eslint/unbound-method
191191
const [mapping] = capture(schemaService.registerMapping).last()
@@ -212,7 +212,8 @@ describe('ResourceManager', function () {
212212
it('deletes resource mapping on file close', async function () {
213213
const editor = await resourceManager.open(resourceNode, false)
214214
await resourceManager.close(editor.document.uri)
215-
verify(schemaService.registerMapping(anything())).twice()
215+
verify(schemaService.registerMapping(anything())).once()
216+
verify(schemaService.registerMapping(anything(), anything())).once()
216217

217218
// eslint-disable-next-line @typescript-eslint/unbound-method
218219
const [mapping] = capture(schemaService.registerMapping).last()

0 commit comments

Comments
 (0)