Skip to content

Commit 055256e

Browse files
authored
Merge #2650 telemetry: AWS filetypes
2 parents d06c1e6 + 5199aaa commit 055256e

18 files changed

+423
-84
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/settings.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -166,18 +166,26 @@ function createSettingsClass<T extends TypeDescriptor>(section: string, descript
166166
type Inner = FromDescriptor<T>
167167

168168
// Class names are not always stable, especially when bundling
169-
function makeLogger(name = 'Settings') {
169+
function makeLogger(name = 'Settings', loglevel: 'debug' | 'error') {
170170
const prefix = `${isNameMangled() ? 'Settings' : name} (${section})`
171-
return (message: string) => getLogger().debug(`${prefix}: ${message}`)
171+
return (message: string) =>
172+
loglevel === 'debug'
173+
? getLogger().debug(`${prefix}: ${message}`)
174+
: getLogger().error(`${prefix}: ${message}`)
172175
}
173176

174177
return class AnonymousSettings implements TypedSettings<Inner> {
175178
private readonly config = this.settings.getSection(section)
176179
private readonly disposables: vscode.Disposable[] = []
177180
// TODO(sijaden): add metadata prop to `Logger` so we don't need to make one-off log functions
178-
protected readonly log = makeLogger(Object.getPrototypeOf(this)?.constructor?.name)
181+
protected readonly log = makeLogger(Object.getPrototypeOf(this)?.constructor?.name, 'debug')
182+
protected readonly logErr = makeLogger(Object.getPrototypeOf(this)?.constructor?.name, 'error')
179183

180-
public constructor(private readonly settings: ClassToInterfaceType<Settings> = Settings.instance) {}
184+
public constructor(
185+
private readonly settings: ClassToInterfaceType<Settings> = Settings.instance,
186+
/** Throw an exception if the user config is invalid or the caller type doesn't match the stored type. */
187+
private readonly throwInvalid: boolean = isAutomation()
188+
) {}
181189

182190
public get onDidChange() {
183191
return this.getChangedEmitter().event
@@ -186,14 +194,11 @@ function createSettingsClass<T extends TypeDescriptor>(section: string, descript
186194
public get<K extends keyof Inner>(key: K & string, defaultValue?: Inner[K]) {
187195
try {
188196
return this.getOrThrow(key, defaultValue)
189-
} catch (error) {
190-
this.log(`failed to read key "${key}": ${error}`)
191-
192-
if (isAutomation() || defaultValue === undefined) {
193-
throw new Error(`Failed to read key "${key}": ${error}`)
197+
} catch (e) {
198+
if (this.throwInvalid || defaultValue === undefined) {
199+
throw new Error(`Failed to read key "${key}": ${e}`)
194200
}
195-
196-
this.log(`using default value for "${key}"`)
201+
this.logErr(`using default for key "${key}", read failed: ${(e as Error).message ?? '?'}`)
197202

198203
return defaultValue
199204
}
@@ -255,8 +260,13 @@ function createSettingsClass<T extends TypeDescriptor>(section: string, descript
255260

256261
for (const key of props.filter(isDifferent)) {
257262
this.log(`key "${key}" changed`)
258-
store[key] = this.get(key)
259-
emitter.fire({ key })
263+
try {
264+
store[key] = this.get(key)
265+
emitter.fire({ key })
266+
} catch {
267+
// getChangedEmitter() can't provide a default value so
268+
// we must silence errors here. Logging is done by this.get().
269+
}
260270
}
261271
})
262272

0 commit comments

Comments
 (0)