Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(302)

Side by Side Diff: pkg/analysis_server/tool/instrumentation/log/log.dart

Issue 2390693002: Handle session logs and add event subset selection (Closed)
Patch Set: Created 4 years, 2 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | pkg/analysis_server/tool/instrumentation/log_viewer.dart » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 // Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file 1 // Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
2 // for details. All rights reserved. Use of this source code is governed by a 2 // for details. All rights reserved. Use of this source code is governed by a
3 // BSD-style license that can be found in the LICENSE file. 3 // BSD-style license that can be found in the LICENSE file.
4 4
5 /** 5 /**
6 * A representation of the contents of an instrumentation log. 6 * A representation of the contents of an instrumentation log.
7 */ 7 */
8 library analysis_server.tool.instrumentation.log; 8 library analysis_server.tool.instrumentation.log.log;
9 9
10 import 'dart:collection';
11 import 'dart:convert'; 10 import 'dart:convert';
12 11
13 import 'package:analyzer/instrumentation/instrumentation.dart'; 12 import 'package:analyzer/instrumentation/instrumentation.dart';
14 13
15 /** 14 /**
15 * A boolean-valued function of one argument.
16 */
17 typedef bool Predicate<T>(T value);
18
19 /**
20 * A description of a group of log entries.
21 */
22 class EntryGroup {
23 /**
24 * A list of all of the instances of this class.
25 */
26 static final List<EntryGroup> groups = <EntryGroup>[
27 new EntryGroup._(
28 'nonTask', 'Non-task', (LogEntry entry) => entry is! TaskEntry),
29 new EntryGroup._(
30 'errors',
31 'Errors',
32 (LogEntry entry) =>
33 entry is ErrorEntry ||
34 entry is ExceptionEntry ||
35 (entry is NotificationEntry && entry.isServerError)),
36 new EntryGroup._('malformed', 'Malformed',
37 (LogEntry entry) => entry is MalformedLogEntry),
38 new EntryGroup._('all', 'All', (LogEntry entry) => true),
39 ];
40
41 /**
42 * The unique id of the group.
43 */
44 final String id;
45
46 /**
47 * The human-readable name of the group.
48 */
49 final String name;
50
51 /**
52 * The filter used to determine which entries belong to the group. The filter
53 * should return `true` for members and `false` for non-members.
54 */
55 final Predicate<LogEntry> filter;
56
57 /**
58 * Initialize a newly created entry group with the given state.
59 */
60 EntryGroup._(this.id, this.name, this.filter);
61
62 /**
63 * Given a list of [entries], return all of the entries in the list that are
64 * members of this group.
65 */
66 List<LogEntry> computeMembers(List<LogEntry> entries) {
67 return entries.where(filter).toList();
68 }
69
70 /**
71 * Return the entry group with the given [id], or `null` if there is no group
72 * with the given id.
73 */
74 static EntryGroup withId(String id) {
75 for (EntryGroup group in groups) {
76 if (group.id == id) {
77 return group;
78 }
79 }
80 return null;
81 }
82 }
83
84 /**
16 * A range of log entries, represented by the index of the first and last 85 * A range of log entries, represented by the index of the first and last
17 * entries in the range. 86 * entries in the range.
18 */ 87 */
19 class EntryRange { 88 class EntryRange {
20 /** 89 /**
21 * The index of the first entry in the range. 90 * The index of the first entry in the range.
22 */ 91 */
23 int firstIndex; 92 int firstIndex;
24 93
25 /** 94 /**
(...skipping 74 matching lines...) Expand 10 before | Expand all | Expand 10 after
100 * The paths of the log files containing the entries. 169 * The paths of the log files containing the entries.
101 */ 170 */
102 List<String> logFilePaths; 171 List<String> logFilePaths;
103 172
104 /** 173 /**
105 * The entries in the instrumentation log. 174 * The entries in the instrumentation log.
106 */ 175 */
107 List<LogEntry> logEntries; 176 List<LogEntry> logEntries;
108 177
109 /** 178 /**
110 * The entries in the instrumentation log that are not instances of 179 * A table mapping the entry groups that have been computed to the list of
111 * [TaskEntry]. 180 * entries in that group.
112 */ 181 */
113 List<LogEntry> nonTaskEntries; 182 Map<EntryGroup, List<LogEntry>> entryGroups = <EntryGroup, List<LogEntry>>{};
114 183
115 /** 184 /**
116 * A table mapping entries that are paired with another entry to the entry 185 * A table mapping entries that are paired with another entry to the entry
117 * with which they are paired. 186 * with which they are paired.
118 */ 187 */
119 Map<LogEntry, LogEntry> _pairedEntries = new HashMap<LogEntry, LogEntry>(); 188 Map<LogEntry, LogEntry> _pairedEntries = <LogEntry, LogEntry>{};
120 189
121 /** 190 /**
122 * A table mapping the id's of requests to the entry representing the request. 191 * A table mapping the id's of requests to the entry representing the request.
123 */ 192 */
124 Map<String, RequestEntry> _requestMap = new HashMap<String, RequestEntry>(); 193 Map<String, RequestEntry> _requestMap = <String, RequestEntry>{};
125 194
126 /** 195 /**
127 * A table mapping the id's of responses to the entry representing the 196 * A table mapping the id's of responses to the entry representing the
128 * response. 197 * response.
129 */ 198 */
130 Map<String, ResponseEntry> _responseMap = 199 Map<String, ResponseEntry> _responseMap = <String, ResponseEntry>{};
131 new HashMap<String, ResponseEntry>();
132 200
133 /** 201 /**
134 * A table mapping the ids of completion events to the events with those ids. 202 * A table mapping the ids of completion events to the events with those ids.
135 */ 203 */
136 Map<String, List<NotificationEntry>> _completionMap = 204 Map<String, List<NotificationEntry>> _completionMap =
137 new HashMap<String, List<NotificationEntry>>(); 205 <String, List<NotificationEntry>>{};
138 206
139 /** 207 /**
140 * The ranges of entries that are between analysis start and analysis end 208 * The ranges of entries that are between analysis start and analysis end
141 * notifications. 209 * notifications.
142 */ 210 */
143 List<EntryRange> analysisRanges; 211 List<EntryRange> analysisRanges;
144 212
145 /** 213 /**
146 * Initialize a newly created instrumentation log by parsing each of the lines 214 * Initialize a newly created instrumentation log by parsing each of the lines
147 * in the [logContent] into a separate entry. The log contents should be the 215 * in the [logContent] into a separate entry. The log contents should be the
148 * contents of the files whose paths are in the given list of [logFilePaths]. 216 * contents of the files whose paths are in the given list of [logFilePaths].
149 */ 217 */
150 InstrumentationLog(this.logFilePaths, List<String> logContent) { 218 InstrumentationLog(this.logFilePaths, List<String> logContent) {
151 _parseLogContent(logContent); 219 _parseLogContent(logContent);
152 } 220 }
153 221
154 /** 222 /**
155 * Return a list of the completion events associated with the given [id]. 223 * Return a list of the completion events associated with the given [id].
156 */ 224 */
157 List<NotificationEntry> completionEventsWithId(String id) => 225 List<NotificationEntry> completionEventsWithId(String id) =>
158 _completionMap[id]; 226 _completionMap[id];
159 227
160 /** 228 /**
229 * Return the log entries that are contained in the given [group].
230 */
231 List<LogEntry> entriesInGroup(EntryGroup group) =>
232 entryGroups.putIfAbsent(group, () => group.computeMembers(logEntries));
233
234 /**
161 * Return the entry that is paired with the given [entry], or `null` if there 235 * Return the entry that is paired with the given [entry], or `null` if there
162 * is no entry paired with it. 236 * is no entry paired with it.
163 */ 237 */
164 LogEntry pairedEntry(LogEntry entry) => _pairedEntries[entry]; 238 LogEntry pairedEntry(LogEntry entry) => _pairedEntries[entry];
165 239
166 /** 240 /**
167 * Return the response that corresponds to the given request. 241 * Return the response that corresponds to the given request.
168 */ 242 */
169 RequestEntry requestFor(ResponseEntry entry) => _requestMap[entry.id]; 243 RequestEntry requestFor(ResponseEntry entry) => _requestMap[entry.id];
170 244
171 /** 245 /**
172 * Return the response that corresponds to the given request. 246 * Return the response that corresponds to the given request.
173 */ 247 */
174 ResponseEntry responseFor(RequestEntry entry) => _responseMap[entry.id]; 248 ResponseEntry responseFor(RequestEntry entry) => _responseMap[entry.id];
175 249
176 /** 250 /**
177 * Return a list containing all of the task entries between the start of 251 * Return a list containing all of the task entries between the start of
178 * analysis notification at the given [startIndex] and the matching end of 252 * analysis notification at the given [startIndex] and the matching end of
179 * analysis notification (or the end of the log if the log does not contain a 253 * analysis notification (or the end of the log if the log does not contain a
180 * corresponding end notification. 254 * corresponding end notification.
181 */ 255 */
182 List<TaskEntry> taskEntriesFor(int startIndex) { 256 List<TaskEntry> taskEntriesFor(int startIndex) {
183 List<TaskEntry> taskEntries = <TaskEntry>[]; 257 List<TaskEntry> taskEntries = <TaskEntry>[];
184 NotificationEntry startEntry = nonTaskEntries[startIndex]; 258 NotificationEntry startEntry = logEntries[startIndex];
185 LogEntry endEntry = pairedEntry(startEntry); 259 LogEntry endEntry = pairedEntry(startEntry);
186 int lastIndex = endEntry == null ? logEntries.length : endEntry.index; 260 int lastIndex = endEntry == null ? logEntries.length : endEntry.index;
187 for (int i = startEntry.index + 1; i < lastIndex; i++) { 261 for (int i = startEntry.index + 1; i < lastIndex; i++) {
188 LogEntry entry = logEntries[i]; 262 LogEntry entry = logEntries[i];
189 if (entry is TaskEntry) { 263 if (entry is TaskEntry) {
190 taskEntries.add(entry); 264 taskEntries.add(entry);
191 } 265 }
192 } 266 }
193 return taskEntries; 267 return taskEntries;
194 } 268 }
195 269
196 /** 270 /**
271 * Return `true` if the given [logContent] appears to be from session data.
272 */
273 bool _isSessionData(List<String> logContent) {
274 if (logContent.length < 2) {
275 return false;
276 }
277 String firstLine = logContent[0];
278 return firstLine.startsWith('-----') && logContent[1].startsWith('~') ||
279 firstLine.startsWith('~');
280 }
281
282 /**
197 * Merge any multi-line entries into a single line so that every element in 283 * Merge any multi-line entries into a single line so that every element in
198 * the given [logContent] is a single entry. 284 * the given [logContent] is a single entry.
199 */ 285 */
200 void _mergeEntries(List<String> logContent) { 286 void _mergeEntries(List<String> logContent) {
201 bool isStartOfEntry(String line) { 287 bool isStartOfEntry(String line) {
202 return line.startsWith(LogEntry.entryRegExp); 288 return line.startsWith(LogEntry.entryRegExp);
203 } 289 }
204 290
205 String merge(String line, List<String> extraLines) { 291 String merge(String line, List<String> extraLines) {
206 StringBuffer buffer = new StringBuffer(); 292 StringBuffer buffer = new StringBuffer();
(...skipping 19 matching lines...) Expand all
226 if (extraLines.isNotEmpty) { 312 if (extraLines.isNotEmpty) {
227 throw new StateError( 313 throw new StateError(
228 '${extraLines.length} non-entry lines before any entry'); 314 '${extraLines.length} non-entry lines before any entry');
229 } 315 }
230 } 316 }
231 317
232 /** 318 /**
233 * Parse the given [logContent] into a list of log entries. 319 * Parse the given [logContent] into a list of log entries.
234 */ 320 */
235 void _parseLogContent(List<String> logContent) { 321 void _parseLogContent(List<String> logContent) {
236 _mergeEntries(logContent); 322 if (_isSessionData(logContent)) {
323 if (logContent[0].startsWith('-----')) {
324 logContent.removeAt(0);
325 }
326 int lastIndex = logContent.length - 1;
327 if (logContent[lastIndex].startsWith('extraction complete')) {
328 logContent.removeAt(lastIndex);
329 }
330 } else {
331 _mergeEntries(logContent);
332 }
237 logEntries = <LogEntry>[]; 333 logEntries = <LogEntry>[];
238 nonTaskEntries = <LogEntry>[];
239 analysisRanges = <EntryRange>[]; 334 analysisRanges = <EntryRange>[];
240 NotificationEntry analysisStartEntry = null; 335 NotificationEntry analysisStartEntry = null;
241 int analysisStartIndex = -1; 336 int analysisStartIndex = -1;
242 NotificationEntry pubStartEntry = null; 337 NotificationEntry pubStartEntry = null;
243 for (String line in logContent) { 338 for (String line in logContent) {
244 LogEntry entry = new LogEntry.from(logEntries.length, line); 339 LogEntry entry = new LogEntry.from(logEntries.length, line);
245 if (entry != null) { 340 if (entry != null) {
246 logEntries.add(entry); 341 logEntries.add(entry);
247 if (entry is! TaskEntry) {
248 nonTaskEntries.add(entry);
249 }
250 if (entry is RequestEntry) { 342 if (entry is RequestEntry) {
251 _requestMap[entry.id] = entry; 343 _requestMap[entry.id] = entry;
252 } else if (entry is ResponseEntry) { 344 } else if (entry is ResponseEntry) {
253 _responseMap[entry.id] = entry; 345 _responseMap[entry.id] = entry;
254 RequestEntry request = _requestMap[entry.id]; 346 RequestEntry request = _requestMap[entry.id];
255 _pairedEntries[entry] = request; 347 _pairedEntries[entry] = request;
256 _pairedEntries[request] = entry; 348 _pairedEntries[request] = entry;
257 } else if (entry is NotificationEntry) { 349 } else if (entry is NotificationEntry) {
258 if (entry.isServerStatus) { 350 if (entry.isServerStatus) {
259 var analysisStatus = entry.param('analysis'); 351 var analysisStatus = entry.param('analysis');
(...skipping 172 matching lines...) Expand 10 before | Expand all | Expand 10 after
432 */ 524 */
433 static final RegExp entryRegExp = new RegExp('[0-9]+\\:'); 525 static final RegExp entryRegExp = new RegExp('[0-9]+\\:');
434 526
435 /** 527 /**
436 * A table mapping kinds to the names of those kinds. 528 * A table mapping kinds to the names of those kinds.
437 */ 529 */
438 static final Map<String, String> kindMap = { 530 static final Map<String, String> kindMap = {
439 'Err': 'Error', 531 'Err': 'Error',
440 'Ex': 'Exception', 532 'Ex': 'Exception',
441 'Log': 'Log message', 533 'Log': 'Log message',
534 'Mal': 'Malformed entry',
442 'Noti': 'Notification', 535 'Noti': 'Notification',
443 'Read': 'Read file', 536 'Read': 'Read file',
444 'Req': 'Request', 537 'Req': 'Request',
445 'Res': 'Response', 538 'Res': 'Response',
446 'Perf': 'Performance data', 539 'Perf': 'Performance data',
447 'SPResult': 'Subprocess result', 540 'SPResult': 'Subprocess result',
448 'SPStart': 'Subprocess start', 541 'SPStart': 'Subprocess start',
449 'Task': 'Task', 542 'Task': 'Task',
450 'Ver': 'Version information', 543 'Ver': 'Version information',
451 'Watch': 'Watch event', 544 'Watch': 'Watch event',
(...skipping 20 matching lines...) Expand all
472 */ 565 */
473 LogEntry(this.index, this.timeStamp); 566 LogEntry(this.index, this.timeStamp);
474 567
475 /** 568 /**
476 * Create a log entry from the given encoded form of the [entry]. 569 * Create a log entry from the given encoded form of the [entry].
477 */ 570 */
478 factory LogEntry.from(int index, String entry) { 571 factory LogEntry.from(int index, String entry) {
479 if (entry.isEmpty) { 572 if (entry.isEmpty) {
480 return null; 573 return null;
481 } 574 }
482 List<String> components = _parseComponents(entry);
483 int timeStamp;
484 try { 575 try {
485 timeStamp = int.parse(components[0]); 576 List<String> components = _parseComponents(entry);
577 int timeStamp;
578 String component = components[0];
579 if (component.startsWith('~')) {
580 component = component.substring(1);
581 }
582 timeStamp = int.parse(component);
583 String entryKind = components[1];
584 if (entryKind == InstrumentationService.TAG_ANALYSIS_TASK) {
585 return new TaskEntry(index, timeStamp, components[2], components[3]);
586 } else if (entryKind == InstrumentationService.TAG_ERROR) {
587 return new ErrorEntry(
588 index, timeStamp, entryKind, components.sublist(2));
589 } else if (entryKind == InstrumentationService.TAG_EXCEPTION) {
590 return new ExceptionEntry(
591 index, timeStamp, entryKind, components.sublist(2));
592 } else if (entryKind == InstrumentationService.TAG_FILE_READ) {
593 // Fall through
594 } else if (entryKind == InstrumentationService.TAG_LOG_ENTRY) {
595 // Fall through
596 } else if (entryKind == InstrumentationService.TAG_NOTIFICATION) {
597 Map requestData = JSON.decode(components[2]);
598 return new NotificationEntry(index, timeStamp, requestData);
599 } else if (entryKind == InstrumentationService.TAG_PERFORMANCE) {
600 // Fall through
601 } else if (entryKind == InstrumentationService.TAG_REQUEST) {
602 Map requestData = JSON.decode(components[2]);
603 return new RequestEntry(index, timeStamp, requestData);
604 } else if (entryKind == InstrumentationService.TAG_RESPONSE) {
605 Map responseData = JSON.decode(components[2]);
606 return new ResponseEntry(index, timeStamp, responseData);
607 } else if (entryKind == InstrumentationService.TAG_SUBPROCESS_START) {
608 // Fall through
609 } else if (entryKind == InstrumentationService.TAG_SUBPROCESS_RESULT) {
610 // Fall through
611 } else if (entryKind == InstrumentationService.TAG_VERSION) {
612 // Fall through
613 } else if (entryKind == InstrumentationService.TAG_WATCH_EVENT) {
614 // Fall through
615 }
616 return new GenericEntry(
617 index, timeStamp, entryKind, components.sublist(2));
486 } catch (exception) { 618 } catch (exception) {
487 print('Invalid time stamp in "${components[0]}"; entry = "$entry"'); 619 LogEntry logEntry = new MalformedLogEntry(index, entry);
488 return null; 620 logEntry.recordProblem(exception.toString());
621 return logEntry;
489 } 622 }
490 String entryKind = components[1];
491 if (entryKind == InstrumentationService.TAG_ANALYSIS_TASK) {
492 return new TaskEntry(index, timeStamp, components[2], components[3]);
493 } else if (entryKind == InstrumentationService.TAG_ERROR) {
494 return new ErrorEntry(index, timeStamp, entryKind, components.sublist(2));
495 } else if (entryKind == InstrumentationService.TAG_EXCEPTION) {
496 return new ExceptionEntry(
497 index, timeStamp, entryKind, components.sublist(2));
498 } else if (entryKind == InstrumentationService.TAG_FILE_READ) {
499 // Fall through
500 } else if (entryKind == InstrumentationService.TAG_LOG_ENTRY) {
501 // Fall through
502 } else if (entryKind == InstrumentationService.TAG_NOTIFICATION) {
503 Map requestData = JSON.decode(components[2]);
504 return new NotificationEntry(index, timeStamp, requestData);
505 } else if (entryKind == InstrumentationService.TAG_PERFORMANCE) {
506 // Fall through
507 } else if (entryKind == InstrumentationService.TAG_REQUEST) {
508 Map requestData = JSON.decode(components[2]);
509 return new RequestEntry(index, timeStamp, requestData);
510 } else if (entryKind == InstrumentationService.TAG_RESPONSE) {
511 Map responseData = JSON.decode(components[2]);
512 return new ResponseEntry(index, timeStamp, responseData);
513 } else if (entryKind == InstrumentationService.TAG_SUBPROCESS_START) {
514 // Fall through
515 } else if (entryKind == InstrumentationService.TAG_SUBPROCESS_RESULT) {
516 // Fall through
517 } else if (entryKind == InstrumentationService.TAG_VERSION) {
518 // Fall through
519 } else if (entryKind == InstrumentationService.TAG_WATCH_EVENT) {
520 // Fall through
521 }
522 return new GenericEntry(index, timeStamp, entryKind, components.sublist(2));
523 } 623 }
524 624
525 /** 625 /**
526 * Return `true` if any problems were found while processing the log file. 626 * Return `true` if any problems were found while processing the log file.
527 */ 627 */
528 bool get hasProblems => _problems != null; 628 bool get hasProblems => _problems != null;
529 629
530 /** 630 /**
531 * Return the value of the component used to indicate the kind of the entry. 631 * Return the value of the component used to indicate the kind of the entry.
532 * This is the abbreviation recorded in the entry. 632 * This is the abbreviation recorded in the entry.
(...skipping 68 matching lines...) Expand 10 before | Expand all | Expand 10 after
601 } else { 701 } else {
602 component.writeCharCode(char); 702 component.writeCharCode(char);
603 } 703 }
604 } 704 }
605 components.add(component.toString()); 705 components.add(component.toString());
606 return components; 706 return components;
607 } 707 }
608 } 708 }
609 709
610 /** 710 /**
711 * A representation of a malformed log entry.
712 */
713 class MalformedLogEntry extends LogEntry {
714 final String entry;
715
716 MalformedLogEntry(int index, this.entry) : super(index, -1);
717
718 @override
719 String get kind => 'Mal';
720
721 @override
722 void _appendDetails(StringBuffer buffer) {
723 super._appendDetails(buffer);
724 buffer.write(entry);
725 buffer.write('<br>');
726 }
727 }
728
729 /**
611 * A log entry representing a notification that was sent from the server to the 730 * A log entry representing a notification that was sent from the server to the
612 * client. 731 * client.
613 */ 732 */
614 class NotificationEntry extends JsonBasedEntry { 733 class NotificationEntry extends JsonBasedEntry {
615 /** 734 /**
616 * Initialize a newly created response to have the given [timeStamp] and 735 * Initialize a newly created response to have the given [timeStamp] and
617 * [notificationData]. 736 * [notificationData].
618 */ 737 */
619 NotificationEntry(int index, int timeStamp, Map notificationData) 738 NotificationEntry(int index, int timeStamp, Map notificationData)
620 : super(index, timeStamp, notificationData); 739 : super(index, timeStamp, notificationData);
621 740
622 /** 741 /**
623 * Return the event field of the request. 742 * Return the event field of the request.
624 */ 743 */
625 String get event => data['event']; 744 String get event => data['event'];
626 745
627 /** 746 /**
747 * Return `true` if this is a server error notification.
748 */
749 bool get isServerError => event == 'server.error';
750
751 /**
628 * Return `true` if this is a server status notification. 752 * Return `true` if this is a server status notification.
629 */ 753 */
630 bool get isServerStatus => event == 'server.status'; 754 bool get isServerStatus => event == 'server.status';
631 755
632 @override 756 @override
633 String get kind => 'Noti'; 757 String get kind => 'Noti';
634 758
635 /** 759 /**
636 * Return the value of the parameter with the given [parameterName], or `null` 760 * Return the value of the parameter with the given [parameterName], or `null`
637 * if there is no such parameter. 761 * if there is no such parameter.
(...skipping 160 matching lines...) Expand 10 before | Expand all | Expand 10 after
798 int slash = context.lastIndexOf('/'); 922 int slash = context.lastIndexOf('/');
799 if (slash < 0) { 923 if (slash < 0) {
800 slash = context.lastIndexOf('\\'); 924 slash = context.lastIndexOf('\\');
801 } 925 }
802 if (slash >= 0) { 926 if (slash >= 0) {
803 String prefix = context.substring(0, slash); 927 String prefix = context.substring(0, slash);
804 _target = _target.replaceAll(prefix, '...'); 928 _target = _target.replaceAll(prefix, '...');
805 } 929 }
806 } 930 }
807 } 931 }
OLDNEW
« no previous file with comments | « no previous file | pkg/analysis_server/tool/instrumentation/log_viewer.dart » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698