Skip to content

Commit ce8cfd4

Browse files
wty27williamhu99anuraaga
authored
Changed TraceConfigz zPage form to use POST request (#1521)
* Removed URLEncoder * Fixed typo * Added URLDecoding * Included comment for string replacement * Added unit tests for special characters in span names * Resolved URL decoding issues * Moved url decoding to parseQueryMap and updated the corresponding unit tests * Added a README file for zPage quickstart * Add images for README * Updated README * Add frontend images * Add backend images * Added our design doc * Added details on package * Reworded a few lines * Moved DESIGN.md to a docs folder and changed gradle config to implementation * Changed wording regarding HttpServer requirement * Added zpages folder under docs, resolved broken image links * Resolved comments for the design md file * Made a few wording changes * Wrote a benchmark test for TracezSpanBuckets (#23) * Scaffolded logic for basic benchmark tests * Wrote benchmark tests for TracezSpanBuckets * Updated README with benchmark tests * Changed the wording slightly * Updated README file (#25) * Wrote benchmark tests for TracezDataAggregator (#24) * Scaffolded logic for basic benchmark tests * Wrote benchmark tests for TracezSpanBuckets * Updated README with benchmark tests * Changed the wording slightly * Added a set of benchmark tests for TracezDataAggregator * Modified README formatting * Changed benchmark test to negate dead code elimination * Added Javadocs to the TracezDataAggregator benchmark tests * Removed benchmark results from README and added a param to the TracezDataAggregator benchmark tests * Update sdk_extensions/zpages/src/jmh/java/io/opentelemetry/sdk/extensions/zpages/TracezDataAggregatorBenchmark.java Co-authored-by: Anuraag Agrawal <[email protected]> * Added multiple param values for TracezDataAggregatorBenchmark * Changed TraceConfigz zPage form submit to use POST request * Added requestMethod parameter to emitHtml, limited TraceConfig change on POST request only * Removed duplicate parse function, added test for update on POST request only * Added separate method for processing request * Removed unnecessary error check in tests, used try resources for inputstream Co-authored-by: williamhu99 <[email protected]> Co-authored-by: William Hu <[email protected]> Co-authored-by: Anuraag Agrawal <[email protected]>
1 parent 7cd931c commit ce8cfd4

File tree

7 files changed

+206
-43
lines changed

7 files changed

+206
-43
lines changed

sdk_extensions/zpages/build.gradle

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,6 @@ dependencies {
1717

1818
compileOnly 'com.sun.net.httpserver:http:20070405'
1919

20-
annotationProcessor libraries.auto_value
21-
22-
testAnnotationProcessor libraries.auto_value
23-
2420
signature "org.codehaus.mojo.signature:java17:1.0@signature"
2521
}
2622

sdk_extensions/zpages/src/main/java/io/opentelemetry/sdk/extensions/zpages/TraceConfigzZPageHandler.java

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,15 +274,15 @@ private void emitHtmlBody(PrintStream out) {
274274
+ ZPageLogo.getLogoBase64()
275275
+ "\" /></a>");
276276
out.print("<h1>Trace Configuration</h1>");
277-
out.print("<form class=\"form-flex\" action=\"" + TRACE_CONFIGZ_URL + "\" method=\"get\">");
277+
out.print("<form class=\"form-flex\" action=\"" + TRACE_CONFIGZ_URL + "\" method=\"post\">");
278278
out.print(
279279
"<input type=\"hidden\" name=\"action\" value=\"" + QUERY_STRING_ACTION_CHANGE + "\" />");
280280
emitChangeTable(out);
281281
// Button for submit
282282
out.print("<button class=\"button\" type=\"submit\" value=\"Submit\">Submit</button>");
283283
out.print("</form>");
284284
// Button for restore default
285-
out.print("<form class=\"form-flex\" action=\"" + TRACE_CONFIGZ_URL + "\" method=\"get\">");
285+
out.print("<form class=\"form-flex\" action=\"" + TRACE_CONFIGZ_URL + "\" method=\"post\">");
286286
out.print(
287287
"<input type=\"hidden\" name=\"action\" value=\"" + QUERY_STRING_ACTION_DEFAULT + "\" />");
288288
out.print("<button class=\"button\" type=\"submit\" value=\"Submit\">Restore Default</button>");
@@ -313,8 +313,6 @@ public void emitHtml(Map<String, String> queryMap, OutputStream outputStream) {
313313
out.print("</head>");
314314
out.print("<body>");
315315
try {
316-
// Apply updated trace configuration based on query parameters
317-
applyTraceConfig(queryMap);
318316
emitHtmlBody(out);
319317
} catch (Throwable t) {
320318
out.print("Error while generating HTML: " + t.toString());
@@ -327,6 +325,44 @@ public void emitHtml(Map<String, String> queryMap, OutputStream outputStream) {
327325
}
328326
}
329327

328+
@Override
329+
public boolean processRequest(
330+
String requestMethod, Map<String, String> queryMap, OutputStream outputStream) {
331+
if (requestMethod.equalsIgnoreCase("POST")) {
332+
try {
333+
applyTraceConfig(queryMap);
334+
} catch (Throwable t) {
335+
try (PrintStream out = new PrintStream(outputStream, /* autoFlush= */ false, "UTF-8")) {
336+
out.print("<!DOCTYPE html>");
337+
out.print("<html lang=\"en\">");
338+
out.print("<head>");
339+
out.print("<meta charset=\"UTF-8\">");
340+
out.print(
341+
"<link rel=\"shortcut icon\" href=\"data:image/png;base64,"
342+
+ ZPageLogo.getFaviconBase64()
343+
+ "\" type=\"image/png\">");
344+
out.print(
345+
"<link href=\"https://fonts.googleapis.com/css?family=Open+Sans:300\""
346+
+ "rel=\"stylesheet\">");
347+
out.print(
348+
"<link href=\"https://fonts.googleapis.com/css?family=Roboto\" rel=\"stylesheet\">");
349+
out.print("<title>" + TRACE_CONFIGZ_NAME + "</title>");
350+
out.print("</head>");
351+
out.print("<body>");
352+
out.print("Error while applying trace config changes: " + t.toString());
353+
out.print("</body>");
354+
out.print("</html>");
355+
logger.log(Level.WARNING, "error while applying trace config changes", t);
356+
} catch (Throwable e) {
357+
logger.log(Level.WARNING, "error while applying trace config changes", e);
358+
return true;
359+
}
360+
return true;
361+
}
362+
}
363+
return false;
364+
}
365+
330366
/**
331367
* Apply updated trace configuration through the tracerProvider based on query parameters.
332368
*

sdk_extensions/zpages/src/main/java/io/opentelemetry/sdk/extensions/zpages/ZPageHandler.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ public abstract class ZPageHandler {
4646
*/
4747
public abstract String getPageDescription();
4848

49+
/**
50+
* Process requests that require changes (POST/PUT/DELETE).
51+
*
52+
* @param requestMethod the request method HttpHandler received.
53+
* @param queryMap the map of the URL query parameters.
54+
* @return true if theres an error while processing the request.
55+
*/
56+
public boolean processRequest(
57+
String requestMethod, Map<String, String> queryMap, OutputStream outputStream) {
58+
// base no-op method
59+
return false;
60+
}
61+
4962
/**
5063
* Emits the generated HTML page to the {@code outputStream}.
5164
*

sdk_extensions/zpages/src/main/java/io/opentelemetry/sdk/extensions/zpages/ZPageHttpHandler.java

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
1919
import com.google.common.annotations.VisibleForTesting;
2020
import com.google.common.base.Splitter;
2121
import com.google.common.collect.ImmutableMap;
22+
import com.google.common.io.CharStreams;
2223
import com.sun.net.httpserver.HttpExchange;
2324
import com.sun.net.httpserver.HttpHandler;
2425
import java.io.IOException;
26+
import java.io.InputStreamReader;
2527
import java.io.UnsupportedEncodingException;
26-
import java.net.URI;
2728
import java.net.URLDecoder;
2829
import java.util.HashMap;
2930
import java.util.List;
@@ -47,19 +48,19 @@ final class ZPageHttpHandler implements HttpHandler {
4748
}
4849

4950
/**
50-
* Build a query map from the {@code uri}.
51+
* Build a query map from the query string.
5152
*
52-
* @param uri the {@link URI} for buiding the query map
53-
* @return the query map built based on the @{code uri}
53+
* @param queryString the query string for buiding the query map.
54+
* @return the query map built based on the query string.
5455
*/
5556
@VisibleForTesting
56-
static ImmutableMap<String, String> parseQueryMap(URI uri) throws UnsupportedEncodingException {
57-
String queryStrings = uri.getRawQuery();
58-
if (queryStrings == null) {
57+
static ImmutableMap<String, String> parseQueryString(String queryString)
58+
throws UnsupportedEncodingException {
59+
if (queryString == null) {
5960
return ImmutableMap.of();
6061
}
6162
Map<String, String> queryMap = new HashMap<String, String>();
62-
for (String param : QUERY_SPLITTER.split(queryStrings)) {
63+
for (String param : QUERY_SPLITTER.split(queryString)) {
6364
List<String> keyValuePair = QUERY_KEYVAL_SPLITTER.splitToList(param);
6465
if (keyValuePair.size() > 1) {
6566
if (keyValuePair.get(0).equals(PARAM_SPAN_NAME)) {
@@ -75,9 +76,25 @@ static ImmutableMap<String, String> parseQueryMap(URI uri) throws UnsupportedEnc
7576
@Override
7677
public final void handle(HttpExchange httpExchange) throws IOException {
7778
try {
79+
String requestMethod = httpExchange.getRequestMethod();
7880
httpExchange.sendResponseHeaders(200, 0);
79-
zpageHandler.emitHtml(
80-
parseQueryMap(httpExchange.getRequestURI()), httpExchange.getResponseBody());
81+
if (requestMethod.equalsIgnoreCase("GET")) {
82+
zpageHandler.emitHtml(
83+
parseQueryString(httpExchange.getRequestURI().getRawQuery()),
84+
httpExchange.getResponseBody());
85+
} else {
86+
final String queryString;
87+
try (InputStreamReader requestBodyReader =
88+
new InputStreamReader(httpExchange.getRequestBody(), "utf-8")) {
89+
queryString = CharStreams.toString(requestBodyReader);
90+
}
91+
boolean error =
92+
zpageHandler.processRequest(
93+
requestMethod, parseQueryString(queryString), httpExchange.getResponseBody());
94+
if (!error) {
95+
zpageHandler.emitHtml(parseQueryString(queryString), httpExchange.getResponseBody());
96+
}
97+
}
8198
} finally {
8299
httpExchange.close();
83100
}

sdk_extensions/zpages/src/test/java/io/opentelemetry/sdk/extensions/zpages/TraceConfigzZPageHandlerTest.java

Lines changed: 111 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,39 @@
2626
import java.io.ByteArrayOutputStream;
2727
import java.io.OutputStream;
2828
import java.util.Map;
29+
import org.junit.jupiter.api.BeforeEach;
2930
import org.junit.jupiter.api.Test;
3031

3132
/** Unit tests for {@link TraceConfigzZPageHandler}. */
3233
public final class TraceConfigzZPageHandlerTest {
3334
private static final TracerSdkProvider tracerProvider = OpenTelemetrySdk.getTracerProvider();
3435
private static final Map<String, String> emptyQueryMap = ImmutableMap.of();
3536

37+
@BeforeEach
38+
void setup() {
39+
// Restore default config
40+
OutputStream output = new ByteArrayOutputStream();
41+
Map<String, String> queryMap = ImmutableMap.of("action", "default");
42+
43+
TraceConfigzZPageHandler traceConfigzZPageHandler =
44+
new TraceConfigzZPageHandler(tracerProvider);
45+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
46+
traceConfigzZPageHandler.emitHtml(queryMap, output);
47+
48+
assertThat(tracerProvider.getActiveTraceConfig().getSampler().getDescription())
49+
.isEqualTo(TraceConfig.getDefault().getSampler().getDescription());
50+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributes())
51+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfAttributes());
52+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfEvents())
53+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfEvents());
54+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfLinks())
55+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfLinks());
56+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributesPerEvent())
57+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfAttributesPerEvent());
58+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributesPerLink())
59+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfAttributesPerLink());
60+
}
61+
3662
@Test
3763
void changeTable_emitRowsCorrectly() {
3864
OutputStream output = new ByteArrayOutputStream();
@@ -154,6 +180,7 @@ void appliesChangesCorrectly_formSubmit() {
154180

155181
TraceConfigzZPageHandler traceConfigzZPageHandler =
156182
new TraceConfigzZPageHandler(tracerProvider);
183+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
157184
traceConfigzZPageHandler.emitHtml(queryMap, output);
158185

159186
assertThat(tracerProvider.getActiveTraceConfig().getSampler().getDescription())
@@ -179,6 +206,7 @@ void appliesChangesCorrectly_restoreDefault() {
179206

180207
TraceConfigzZPageHandler traceConfigzZPageHandler =
181208
new TraceConfigzZPageHandler(tracerProvider);
209+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
182210
traceConfigzZPageHandler.emitHtml(queryMap, output);
183211

184212
assertThat(tracerProvider.getActiveTraceConfig().getSampler().getDescription())
@@ -203,6 +231,7 @@ void appliesChangesCorrectly_doNotCrashOnNullParameters() {
203231

204232
TraceConfigzZPageHandler traceConfigzZPageHandler =
205233
new TraceConfigzZPageHandler(tracerProvider);
234+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
206235
traceConfigzZPageHandler.emitHtml(queryMap, output);
207236

208237
assertThat(tracerProvider.getActiveTraceConfig().getSampler().getDescription())
@@ -228,69 +257,69 @@ void applyChanges_emitErrorOnInvalidInput() {
228257
Map<String, String> queryMap =
229258
ImmutableMap.of("action", "change", "samplingprobability", "invalid");
230259

231-
traceConfigzZPageHandler.emitHtml(queryMap, output);
260+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
232261

233-
assertThat(output.toString()).contains("Error while generating HTML: ");
262+
assertThat(output.toString()).contains("Error while applying trace config changes: ");
234263
assertThat(output.toString()).contains("SamplingProbability must be of the type double");
235264

236265
// Invalid samplingProbability (< 0)
237266
output = new ByteArrayOutputStream();
238267
traceConfigzZPageHandler = new TraceConfigzZPageHandler(tracerProvider);
239268
queryMap = ImmutableMap.of("action", "change", "samplingprobability", "-1");
240269

241-
traceConfigzZPageHandler.emitHtml(queryMap, output);
270+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
242271

243-
assertThat(output.toString()).contains("Error while generating HTML: ");
272+
assertThat(output.toString()).contains("Error while applying trace config changes: ");
244273
assertThat(output.toString()).contains("probability must be in range [0.0, 1.0]");
245274

246275
// Invalid samplingProbability (> 1)
247276
output = new ByteArrayOutputStream();
248277
traceConfigzZPageHandler = new TraceConfigzZPageHandler(tracerProvider);
249278
queryMap = ImmutableMap.of("action", "change", "samplingprobability", "1.1");
250279

251-
traceConfigzZPageHandler.emitHtml(queryMap, output);
280+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
252281

253-
assertThat(output.toString()).contains("Error while generating HTML: ");
282+
assertThat(output.toString()).contains("Error while applying trace config changes: ");
254283
assertThat(output.toString()).contains("probability must be in range [0.0, 1.0]");
255284

256285
// Invalid maxNumOfAttributes
257286
output = new ByteArrayOutputStream();
258287
traceConfigzZPageHandler = new TraceConfigzZPageHandler(tracerProvider);
259288
queryMap = ImmutableMap.of("action", "change", "maxnumofattributes", "invalid");
260289

261-
traceConfigzZPageHandler.emitHtml(queryMap, output);
290+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
262291

263-
assertThat(output.toString()).contains("Error while generating HTML: ");
292+
assertThat(output.toString()).contains("Error while applying trace config changes: ");
264293
assertThat(output.toString()).contains("MaxNumOfAttributes must be of the type integer");
265294

266295
// Invalid maxNumOfEvents
267296
output = new ByteArrayOutputStream();
268297
traceConfigzZPageHandler = new TraceConfigzZPageHandler(tracerProvider);
269298
queryMap = ImmutableMap.of("action", "change", "maxnumofevents", "invalid");
270299

271-
traceConfigzZPageHandler.emitHtml(queryMap, output);
300+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
272301

273-
assertThat(output.toString()).contains("Error while generating HTML: ");
302+
assertThat(output.toString()).contains("Error while applying trace config changes: ");
274303
assertThat(output.toString()).contains("MaxNumOfEvents must be of the type integer");
275304

276305
// Invalid maxNumLinks
277306
output = new ByteArrayOutputStream();
278307
traceConfigzZPageHandler = new TraceConfigzZPageHandler(tracerProvider);
279308
queryMap = ImmutableMap.of("action", "change", "maxnumoflinks", "invalid");
280309

281-
traceConfigzZPageHandler.emitHtml(queryMap, output);
310+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
282311

283-
assertThat(output.toString()).contains("Error while generating HTML: ");
312+
assertThat(output.toString()).contains("Error while applying trace config changes: ");
284313
assertThat(output.toString()).contains("MaxNumOfLinks must be of the type integer");
285314

286315
// Invalid maxNumOfAttributesPerEvent
287316
output = new ByteArrayOutputStream();
288317
traceConfigzZPageHandler = new TraceConfigzZPageHandler(tracerProvider);
289318
queryMap = ImmutableMap.of("action", "change", "maxnumofattributesperevent", "invalid");
290319

291-
traceConfigzZPageHandler.emitHtml(queryMap, output);
320+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
292321

293-
assertThat(output.toString()).contains("Error while generating HTML: ");
322+
assertThat(output.toString()).contains("Error while applying trace config changes: ");
294323
assertThat(output.toString())
295324
.contains("MaxNumOfAttributesPerEvent must be of the type integer");
296325

@@ -299,9 +328,75 @@ void applyChanges_emitErrorOnInvalidInput() {
299328
traceConfigzZPageHandler = new TraceConfigzZPageHandler(tracerProvider);
300329
queryMap = ImmutableMap.of("action", "change", "maxnumofattributesperlink", "invalid");
301330

302-
traceConfigzZPageHandler.emitHtml(queryMap, output);
331+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
303332

304-
assertThat(output.toString()).contains("Error while generating HTML: ");
333+
assertThat(output.toString()).contains("Error while applying trace config changes: ");
305334
assertThat(output.toString()).contains("MaxNumOfAttributesPerLink must be of the type integer");
306335
}
336+
337+
@Test
338+
void applyChanges_shouldNotUpdateOnGetRequest() {
339+
OutputStream output = new ByteArrayOutputStream();
340+
String querySamplingProbability = "samplingprobability";
341+
String queryMaxNumOfAttributes = "maxnumofattributes";
342+
String queryMaxNumOfEvents = "maxnumofevents";
343+
String queryMaxNumOfLinks = "maxnumoflinks";
344+
String queryMaxNumOfAttributesPerEvent = "maxnumofattributesperevent";
345+
String queryMaxNumOfAttributesPerLink = "maxnumofattributesperlink";
346+
String newSamplingProbability = "0.001";
347+
String newMaxNumOfAttributes = "16";
348+
String newMaxNumOfEvents = "16";
349+
String newMaxNumOfLinks = "16";
350+
String newMaxNumOfAttributesPerEvent = "16";
351+
String newMaxNumOfAttributesPerLink = "16";
352+
353+
// Apply new config
354+
Map<String, String> queryMap =
355+
new ImmutableMap.Builder<String, String>()
356+
.put("action", "change")
357+
.put(querySamplingProbability, newSamplingProbability)
358+
.put(queryMaxNumOfAttributes, newMaxNumOfAttributes)
359+
.put(queryMaxNumOfEvents, newMaxNumOfEvents)
360+
.put(queryMaxNumOfLinks, newMaxNumOfLinks)
361+
.put(queryMaxNumOfAttributesPerEvent, newMaxNumOfAttributesPerEvent)
362+
.put(queryMaxNumOfAttributesPerLink, newMaxNumOfAttributesPerLink)
363+
.build();
364+
365+
TraceConfigzZPageHandler traceConfigzZPageHandler =
366+
new TraceConfigzZPageHandler(tracerProvider);
367+
368+
// GET request, Should not apply changes
369+
traceConfigzZPageHandler.emitHtml(queryMap, output);
370+
371+
assertThat(tracerProvider.getActiveTraceConfig().getSampler().getDescription())
372+
.isEqualTo(TraceConfig.getDefault().getSampler().getDescription());
373+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributes())
374+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfAttributes());
375+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfEvents())
376+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfEvents());
377+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfLinks())
378+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfLinks());
379+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributesPerEvent())
380+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfAttributesPerEvent());
381+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributesPerLink())
382+
.isEqualTo(TraceConfig.getDefault().getMaxNumberOfAttributesPerLink());
383+
384+
// POST request, Should apply changes
385+
traceConfigzZPageHandler.processRequest("POST", queryMap, output);
386+
traceConfigzZPageHandler.emitHtml(queryMap, output);
387+
388+
assertThat(tracerProvider.getActiveTraceConfig().getSampler().getDescription())
389+
.isEqualTo(
390+
Samplers.probability(Double.parseDouble(newSamplingProbability)).getDescription());
391+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributes())
392+
.isEqualTo(Integer.parseInt(newMaxNumOfAttributes));
393+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfEvents())
394+
.isEqualTo(Integer.parseInt(newMaxNumOfEvents));
395+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfLinks())
396+
.isEqualTo(Integer.parseInt(newMaxNumOfLinks));
397+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributesPerEvent())
398+
.isEqualTo(Integer.parseInt(newMaxNumOfAttributesPerEvent));
399+
assertThat(tracerProvider.getActiveTraceConfig().getMaxNumberOfAttributesPerLink())
400+
.isEqualTo(Integer.parseInt(newMaxNumOfAttributesPerLink));
401+
}
307402
}

0 commit comments

Comments
 (0)