Skip to content

Commit 979b97c

Browse files
authored
refactor(trace): simplify how construct traces get built (#34355)
This change is motivated by the tree metadata containing a `parent` pointer that used to be stripped before writing it to disk in `tree.json`. I removed the `parent` pointer because shouldn't be in this data structure at all. The TreeMetadata `parent` pointer was being used to build a `ConstructTrace` for the purposes of reporting template validation errors, so that code had to be refactored as well. It was doing a complicated dance that I honestly didn't quite understand going up and down the construct tree trace and holding cached copies of various data structures in memory. I removed the dependency of the trace reporting on `TreeMetadata` and replaced it with a path traversal from the root of the construct tree, and progressively building the `ConstructTrace` from the constructs we find along the way: first building a chain of `ConstructTrace` objects without `child` and `location` information, and then adding it on later. One note on what's left here: - As far as I can tell we are peeling apart a stack trace from the lowest-level `CfnResource` into a different single frames, to be reported in the `ConstructTrace`. I *think* I did a faithful conversion, but what I have only works if child constructs are always created in constructors. Our counting of stack frames will be off by one for every method call involved (in other words, if more than 1 stack frame separates 2 levels in the construct tree). I'm not fixing this right now because that's not what I want to focus on, but this could/should probably be a backlog item? ------ As an example of the latter, let's say this construct tree: ``` StaticWebsite | +-- Bucket | +--- CfnResource <-- stack trace only available here ``` The way we will do this is we'll take the stack trace from `CfnResource`, and use it as follows: - `stackTrace[0]` -> that must be where CfnResource is created - `stackTrace[1]` -> that must be where Bucket is created - `stackTrace[2]` -> that must be where StaticWebsite is created The above assumptions hold for the following code: ```ts // static-website.ts class StaticWebsite { constructor() { new Bucket(this, 'Bucket'); } } // app.ts new StaticWebsite(); ``` But *don't* hold for the following code: ```ts // static-website.ts class StaticWebsite { constructor() this.createStaticHtmlBucket(); } private createStaticHtmlBucket() { new Bucket(this, 'Bucket'); } } // app.ts new StaticWebsite(); ``` Because the initialization of `StaticWebsite` now comprises 2 stack frames instead of 1. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 4e3df41 commit 979b97c

File tree

4 files changed

+83
-218
lines changed

4 files changed

+83
-218
lines changed

packages/aws-cdk-lib/core/lib/private/tree-metadata.ts

Lines changed: 2 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,14 @@ import { ISynthesisSession } from '../stack-synthesizers';
1010
import { IInspectable, TreeInspector } from '../tree';
1111

1212
const FILE_PATH = 'tree.json';
13-
type Mutable<T> = {
14-
-readonly [P in keyof T]: Mutable<T[P]>;
15-
};
13+
1614
/**
1715
* Construct that is automatically attached to the top-level `App`.
1816
* This generates, as part of synthesis, a file containing the construct tree and the metadata for each node in the tree.
1917
* The output is in a tree format so as to preserve the construct hierarchy.
2018
*
2119
*/
2220
export class TreeMetadata extends Construct {
23-
private _tree?: { [path: string]: Node };
2421
constructor(scope: Construct) {
2522
super(scope, 'Tree');
2623
}
@@ -45,15 +42,9 @@ export class TreeMetadata extends Construct {
4542
.filter((child) => child !== undefined)
4643
.reduce((map, child) => Object.assign(map, { [child!.id]: child }), {});
4744

48-
const parent = construct.node.scope;
4945
const node: Node = {
5046
id: construct.node.id || 'App',
5147
path: construct.node.path,
52-
parent: parent && parent.node.path ? {
53-
id: parent.node.id,
54-
path: parent.node.path,
55-
constructInfo: constructInfoFromConstruct(parent),
56-
} : undefined,
5748
children: Object.keys(childrenMap).length === 0 ? undefined : childrenMap,
5849
attributes: this.synthAttributes(construct),
5950
constructInfo: constructInfoFromConstruct(construct),
@@ -68,16 +59,8 @@ export class TreeMetadata extends Construct {
6859
version: 'tree-0.1',
6960
tree: visit(this.node.root),
7061
};
71-
this._tree = lookup;
72-
7362
const builder = session.assembly;
74-
fs.writeFileSync(path.join(builder.outdir, FILE_PATH), JSON.stringify(tree, (key: string, value: any) => {
75-
// we are adding in the `parent` attribute for internal use
76-
// and it doesn't make much sense to include it in the
77-
// tree.json
78-
if (key === 'parent') return undefined;
79-
return value;
80-
}), { encoding: 'utf-8' });
63+
fs.writeFileSync(path.join(builder.outdir, FILE_PATH), JSON.stringify(tree), { encoding: 'utf-8' });
8164

8265
builder.addArtifact('Tree', {
8366
type: ArtifactType.CDK_TREE,
@@ -87,76 +70,6 @@ export class TreeMetadata extends Construct {
8770
});
8871
}
8972

90-
/**
91-
* Each node will only have 1 level up (node.parent.parent will always be undefined)
92-
* so we need to reconstruct the node making sure the parents are set
93-
*/
94-
private getNodeWithParents(node: Node): Node {
95-
if (!this._tree) {
96-
throw new Error(`attempting to get node branch for ${node.path}, but the tree has not been created yet!`);
97-
}
98-
let tree = node;
99-
if (node.parent) {
100-
tree = {
101-
...node,
102-
parent: this.getNodeWithParents(this._tree[node.parent.path]),
103-
};
104-
}
105-
return tree;
106-
}
107-
108-
/**
109-
* Construct a new tree with only the nodes that we care about.
110-
* Normally each node can contain many child nodes, but we only care about the
111-
* tree that leads to a specific construct so drop any nodes not in that path
112-
*
113-
* @param node Node the current tree node
114-
* @returns Node the root node of the new tree
115-
*/
116-
private renderTreeWithChildren(node: Node): Node {
117-
/**
118-
* @param currentNode - The current node being evaluated
119-
* @param currentNodeChild - The previous node which should be the only child of the current node
120-
* @returns The node with all children removed except for the path to the current node
121-
*/
122-
function renderTreeWithSingleChild(currentNode: Mutable<Node>, currentNodeChild: Mutable<Node>) {
123-
currentNode.children = {
124-
[currentNodeChild.id]: currentNodeChild,
125-
};
126-
if (currentNode.parent) {
127-
currentNode.parent = renderTreeWithSingleChild(currentNode.parent, currentNode);
128-
}
129-
return currentNode;
130-
}
131-
132-
const currentNode = node.parent ? renderTreeWithSingleChild(node.parent, node) : node;
133-
// now that we have the new tree we need to return the root node
134-
let root = currentNode;
135-
do {
136-
if (root.parent) {
137-
root = root.parent;
138-
}
139-
} while (root.parent);
140-
141-
return root;
142-
}
143-
144-
/**
145-
* This gets a specific "branch" of the tree for a given construct path.
146-
* It will return the root Node of the tree with non-relevant branches filtered
147-
* out (i.e. node children that don't traverse to the given construct path)
148-
*
149-
* @internal
150-
*/
151-
public _getNodeBranch(constructPath: string): Node | undefined {
152-
if (!this._tree) {
153-
throw new Error(`attempting to get node branch for ${constructPath}, but the tree has not been created yet!`);
154-
}
155-
const tree = this._tree[constructPath];
156-
const treeWithParents = this.getNodeWithParents(tree);
157-
return this.renderTreeWithChildren(treeWithParents);
158-
}
159-
16073
private synthAttributes(construct: IConstruct): { [key: string]: any } | undefined {
16174
// check if a construct implements IInspectable
16275
function canInspect(inspectable: any): inspectable is IInspectable {
@@ -177,7 +90,6 @@ export class TreeMetadata extends Construct {
17790
export interface Node {
17891
readonly id: string;
17992
readonly path: string;
180-
readonly parent?: Node;
18193
readonly children?: { [key: string]: Node };
18294
readonly attributes?: { [key: string]: any };
18395

Lines changed: 77 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Construct, IConstruct } from 'constructs';
22
import { App } from '../../app';
33
import { CfnResource } from '../../cfn-resource';
4-
import { TreeMetadata, Node } from '../../private/tree-metadata';
4+
import { constructInfoFromConstruct } from '../../helpers-internal';
55
import { Stack } from '../../stack';
66

77
/**
@@ -65,19 +65,15 @@ export class ConstructTree {
6565
/**
6666
* A cache of the ConstructTrace by node.path. Each construct
6767
*/
68-
private readonly _traceCache = new Map<string, ConstructTrace>();
6968
private readonly _constructByPath = new Map<string, Construct>();
7069
private readonly _constructByTemplatePathAndLogicalId = new Map<string, Map<string, Construct>>();
71-
private readonly treeMetadata: TreeMetadata;
70+
private readonly root: IConstruct;
7271

7372
constructor(
74-
private readonly root: IConstruct,
73+
root: IConstruct,
7574
) {
76-
if (App.isApp(this.root)) {
77-
this.treeMetadata = this.root.node.tryFindChild('Tree') as TreeMetadata;
78-
} else {
79-
this.treeMetadata = App.of(this.root)?.node.tryFindChild('Tree') as TreeMetadata;
80-
}
75+
this.root = App.of(root) ?? root;
76+
8177
this._constructByPath.set(this.root.node.path, root);
8278
// do this once at the start so we don't have to traverse
8379
// the entire tree everytime we want to find a nested node
@@ -110,126 +106,91 @@ export class ConstructTree {
110106
}
111107

112108
/**
113-
* Get the stack trace from the construct node metadata.
114-
* The stack trace only gets recorded if the node is a `CfnResource`,
115-
* but the stack trace will have entries for all types of parent construct
116-
* scopes
117-
*/
118-
private getTraceMetadata(size: number, node?: Node): string[] {
119-
if (node) {
120-
const construct = this.getConstructByPath(node.path);
121-
if (construct) {
122-
let trace;
123-
if (CfnResource.isCfnResource(construct)) {
124-
trace = construct.node.metadata.find(meta => !!meta.trace)?.trace ?? [];
125-
} else {
126-
trace = construct.node.defaultChild?.node.metadata.find(meta => !!meta.trace)?.trace ?? [];
127-
}
128-
// take just the items we need and reverse it since we are
129-
// displaying the trace bottom up
130-
return Object.create(trace.slice(0, size));
131-
}
132-
}
133-
return [];
134-
}
135-
136-
/**
137-
* Only the `CfnResource` constructs contain the trace information
138-
* So we need to go down the tree and find that resource (its always the last one)
109+
* Turn a construct path into a trace
139110
*
140-
* @param node Node the entire tree where the bottom is the violating resource
141-
* @return Node the bottom of the tree which will be the violating resource
142-
*/
143-
private getNodeWithTrace(node: Node): Node {
144-
if (node.children) {
145-
return this.getNodeWithTrace(this.getChild(node.children));
111+
* The trace contains all constructs from the root to the construct indicated
112+
* by the given path. It does not include the root itself.
113+
*/
114+
public traceFromPath(constructPath: string): ConstructTrace {
115+
// Deal with a path starting with `/`.
116+
const components = constructPath.replace(/^\//, '').split('/');
117+
118+
const rootPath: Array<ReturnType<ConstructTree['constructTraceLevelFromTreeNode']>> = [];
119+
const stackTraces: Array<string[] | undefined> = [];
120+
let node: IConstruct | undefined = this.root;
121+
while (node) {
122+
rootPath.push(this.constructTraceLevelFromTreeNode(node));
123+
stackTraces.push(this.stackTrace(node));
124+
125+
const component = components.shift()!;
126+
node = component !== undefined ? node.node.tryFindChild(component) : undefined;
146127
}
147-
return node;
148-
}
149-
150-
/**
151-
* @param node - the root node of the tree
152-
* @returns the terminal node in the tree
153-
*/
154-
private lastChild(node: Node): Node {
155-
if (node.children) {
156-
return this.lastChild(this.getChild(node.children));
128+
// Remove the root from the levels
129+
rootPath.shift();
130+
stackTraces.shift();
131+
132+
// Now turn the rootpath into a proper ConstructTrace tree by making every next element in the
133+
// array the 'child' of the previous one, and adding stack traces.
134+
let ret: ConstructTrace = {
135+
...rootPath[rootPath.length - 1],
136+
location: stackLocationAt(rootPath.length - 1),
137+
};
138+
for (let i = rootPath.length - 2; i >= 0; i--) {
139+
ret = {
140+
...rootPath[i],
141+
child: ret,
142+
location: stackLocationAt(i),
143+
};
144+
}
145+
return ret;
146+
147+
/**
148+
* Get the stack location for construct `i` in the list
149+
*
150+
* If there is a stack trace for the given construct, then use it and get the
151+
* first frame from it. Otherwise, find the first stack trace below this
152+
* construct and use the N'th frame from it, where N is the distance from this
153+
* construct to the construct with stack trace.
154+
*
155+
* This assumes all constructs are created without additional functions
156+
* calls in the middle.
157+
*/
158+
function stackLocationAt(i: number) {
159+
let j = i;
160+
while (j < rootPath.length && !stackTraces[j]) {
161+
j++;
162+
}
163+
if (j === rootPath.length) {
164+
return STACK_TRACE_HINT;
165+
}
166+
// Stack traces are closest frame first
167+
return stackTraces[j]?.[0 + j - i] ?? STACK_TRACE_HINT;
157168
}
158-
return node;
159169
}
160170

161171
/**
162-
* Get a ConstructTrace from the cache for a given construct
172+
* Convert a Tree Metadata Node into a ConstructTrace object, except its child and stack trace info
163173
*
164-
* Construct the stack trace of constructs. This will start with the
165-
* root of the tree and go down to the construct that has the violation
174+
* FIXME: This could probably use the construct tree directly.
166175
*/
167-
public getTrace(node: Node, locations?: string[]): ConstructTrace | undefined {
168-
const lastChild = this.lastChild(node);
169-
const trace = this._traceCache.get(lastChild.path);
170-
if (trace) {
171-
return trace;
172-
}
176+
public constructTraceLevelFromTreeNode(node: IConstruct): Omit<ConstructTrace, 'child' | 'location'> {
177+
const constructInfo = constructInfoFromConstruct(node);
173178

174-
const size = this.nodeSize(node);
175-
176-
const nodeWithTrace = this.getNodeWithTrace(node);
177-
const metadata = (locations ?? this.getTraceMetadata(size, nodeWithTrace));
178-
const thisLocation = metadata.pop();
179-
180-
const constructTrace: ConstructTrace = {
181-
id: node.id,
182-
path: node.path,
183-
// the "child" trace will be the "parent" node
184-
// since we are going bottom up
185-
child: node.children
186-
? this.getTrace(this.getChild(node.children), metadata)
187-
: undefined,
188-
construct: node.constructInfo?.fqn,
189-
libraryVersion: node.constructInfo?.version,
190-
location: thisLocation ?? "Run with '--debug' to include location info",
179+
return {
180+
id: node.node.id,
181+
path: node.node.path,
182+
construct: constructInfo?.fqn,
183+
libraryVersion: constructInfo?.version,
191184
};
192-
// set the cache for the last child path. If the last child path is different then
193-
// we have a different tree and need to retrieve the trace again
194-
this._traceCache.set(lastChild.path, constructTrace);
195-
return constructTrace;
196-
}
197-
198-
/**
199-
* Each node will only have a single child so just
200-
* return that
201-
*/
202-
private getChild(children: { [key: string]: Node }): Node {
203-
return Object.values(children)[0];
204185
}
205186

206187
/**
207-
* Get the size of a Node, i.e. how many levels is it
208-
*/
209-
private nodeSize(node: Node): number {
210-
let size = 1;
211-
if (!node.children) {
212-
return size;
213-
}
214-
let children: Node | undefined = this.getChild(node.children);
215-
do {
216-
size++;
217-
children = children.children
218-
? this.getChild(children.children)
219-
: undefined;
220-
} while (children);
221-
222-
return size;
223-
}
224-
225-
/**
226-
* Get a specific node in the tree by construct path
188+
* Return the stack trace for a given construct path
227189
*
228-
* @param path the construct path of the node to return
229-
* @returns the TreeMetadata Node
190+
* Returns a stack trace if stack trace information is found, or `undefined` if not.
230191
*/
231-
public getTreeNode(path: string): Node | undefined {
232-
return this.treeMetadata._getNodeBranch(path);
192+
private stackTrace(construct: IConstruct): string[] | undefined {
193+
return construct?.node.metadata.find(meta => !!meta.trace)?.trace;
233194
}
234195

235196
/**
@@ -253,3 +214,5 @@ export class ConstructTree {
253214
return this._constructByTemplatePathAndLogicalId.get(templateFile)?.get(logicalId);
254215
}
255216
}
217+
218+
const STACK_TRACE_HINT = 'Run with \'--debug\' to include location info';

packages/aws-cdk-lib/core/lib/validation/private/report.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export class PolicyValidationReportFormatter {
212212
resource.resourceLogicalId,
213213
)?.node.path;
214214
return {
215-
constructStack: this.reportTrace.formatJson(constructPath),
215+
constructStack: constructPath ? this.reportTrace.formatJson(constructPath) : undefined,
216216
constructPath: constructPath,
217217
locations: resource.locations,
218218
resourceLogicalId: resource.resourceLogicalId,

0 commit comments

Comments
 (0)